initial commit

This commit is contained in:
2026-06-09 14:07:37 +03:30
parent 30ac533800
commit 996a4fcda7
121 changed files with 20557 additions and 3 deletions

55
.env.example Normal file
View File

@@ -0,0 +1,55 @@
# Application
PORT=8085
NODE_ENV=development
# MongoDB
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
# Authentication
API_KEY=your-secure-api-key-here
JWT_SECRET=change-me-use-long-random-string
JWT_REFRESH_SECRET=change-me-refresh-secret
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
JWT_ENABLED=true
BCRYPT_SALT_ROUNDS=12
# Rate limiting
THROTTLE_TTL=60
THROTTLE_LIMIT=100
# Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=MOALLEM,TEJARATNOU
CAR_PLATE_DEFAULT_PROVIDER=HAMTA
CAR_PLATE_FALLBACK_ENABLED=true
CAR_PLATE_FALLBACK_PROVIDERS=
# Hamta provider
HAMTA_BASE_URL=https://api.hamta.example.com
HAMTA_USERNAME=
HAMTA_PASSWORD=
HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true
HAMTA_MAX_RETRIES=2
# Moallem provider (identical API structure to Hamta)
MOALLEM_BASE_URL=https://api.moallem.example.com
MOALLEM_USERNAME=
MOALLEM_PASSWORD=
MOALLEM_SECRET_KEY=
MOALLEM_TIMEOUT=10000
MOALLEM_ENABLED=true
MOALLEM_MAX_RETRIES=2
# TejaratNou provider
TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir
TEJARATNOU_CLIENT_ID=api-gateway
TEJARATNOU_CLIENT_SECRET=your_client_secret
TEJARATNOU_USERNAME=your_username
TEJARATNOU_PASSWORD=your_password
TEJARATNOU_TIMEOUT=15000
TEJARATNOU_ENABLED=true

253
README.md
View File

@@ -1,3 +1,252 @@
# esg
# Inquiry Gateway
External Services Gateway. This project is for inquiry for all external services outside of company.
Production-grade **NestJS + MongoDB** unified inquiry gateway for external service providers.
## 📚 Documentation
- **[Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md)** - Complete guide for configuring providers, authentication, and inquiry routing
- **[API Documentation](docs/API_DOCUMENTATION.md)** - Full API reference with examples, Swagger definitions, and best practices
## Architecture
```
Client → POST /inquiries/person (unified)
→ InquiryController
→ InquiryService
→ ProviderOrchestratorService (strategy + fallback)
→ ProviderFactory → Hamta | Moallem | TejaratNou
→ LegacyApiProvider (shared base for identical APIs)
→ BaseProvider (retry, timeout, logging, error normalization)
→ MongoDB inquiry_logs
```
### Provider vs Authentication Service
The system distinguishes between:
- **Inquiry Providers** (HAMTA, MOALLEM, TEJARATNOU): Handle actual inquiry requests
- **Authentication Services** (AMITIS): Provide authentication tokens for providers
Each inquiry type can be configured with its own credentials and authentication method. See [Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md) for details.
### Design patterns
| Pattern | Usage |
|---------|--------|
| **Strategy** | `ProviderOrchestratorService` selects and executes providers |
| **Factory** | `ProviderFactory` registers and resolves provider instances |
| **Template method** | `BaseProvider` defines execution pipeline; subclasses implement `callProvider` |
| **Shared adapter** | `LegacyApiProvider` for Hamta/Moallem identical APIs |
### Project structure
```
src/
├── main.ts
├── app.module.ts
├── common/ # DTOs, filters, interceptors, helpers, enums
├── config/ # @nestjs/config + inquiry routing
├── auth/ # JWT, RBAC, inquiry access guards
├── users/ # Unified users (ADMIN / USER / SUPER_ADMIN)
├── audit/ # Security audit logs
├── rate-limit/ # Global + per-user throttling
├── logging/ # MongoDB inquiry audit logs
├── providers/ # BaseProvider, LegacyApiProvider, factory, orchestrator
└── inquiry/ # Unified controllers & services
```
## Quick start
```bash
cp .env.example .env
# Edit .env with your provider credentials (see Environment Configuration Guide)
npm install
npm run cli:create-super-admin # interactive — creates SUPER_ADMIN
npm run start:dev
```
- API: `http://localhost:8085`
- Swagger: `http://localhost:8085/api/docs`
## Authentication & users
| Role | Capabilities |
|------|----------------|
| `SUPER_ADMIN` | Full access; create any user including other super admins |
| `ADMIN` | Manage users; all inquiry types |
| `USER` | Inquiry access per `allowedInquiries` |
**Auth endpoints:** `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`, `GET /auth/profile`
**User management:** `POST/GET/PATCH /users`, block/unblock, reset password (ADMIN+ only)
```bash
# Login
curl -X POST http://localhost:8085/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your-password"}'
# Person inquiry (JWT)
curl -X POST http://localhost:8085/inquiries/person \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <accessToken>" \
-d '{"nationalCode":"0012345678","birthDate":"1370-05-15"}'
```
Response envelope (`BaseInquiryResponseDto`):
```json
{
"success": true,
"provider": "HAMTA",
"trackingCode": "INQ-20250525-A1B2C3D4",
"message": "Person inquiry completed successfully",
"data": { "nationalCode": "0012345678", "birthDate": "1370-05-15", "fullName": "..." },
"duration": 342
}
```
## Provider configuration
Per-inquiry configuration via environment variables:
```env
# Default provider and fallback chain
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_PROVIDERS=MOALLEM
# Per-inquiry credentials and authentication
HAMTA_PERSON_URL=https://api.example.com/person
HAMTA_PERSON_USERNAME=user123
HAMTA_PERSON_PASSWORD=pass123
HAMTA_PERSON_AUTH_METHOD=AMITIS
# AMITIS authentication service
AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login
AMITIS_REFRESH_URL=https://auth.services.centinsur.ir/api/security/RefreshToken
```
See [Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md) for complete configuration options.
## Supported inquiry types
| Inquiry Type | Providers | Description |
|--------------|-----------|-------------|
| `PERSON_INQUIRY` | HAMTA, MOALLEM | Person information by national code and birth date |
| `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information |
| `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code |
| `SHAHKAR_INQUIRY` | HAMTA, MOALLEM | Mobile number verification |
| `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data |
| `SHEBA_INQUIRY` | MOALLEM | Bank account (SHEBA) validation |
| `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry |
See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats.
## Authentication methods
| Method | Description | Use Case |
|--------|-------------|----------|
| `AMITIS` | Token-based auth via AMITIS service | Most CentInsur APIs |
| `SOAP` | Direct SOAP authentication | Legacy XML-based services |
| `OAUTH2` | OAuth 2.0 flow | Modern REST APIs |
| `NONE` | No authentication required | Public APIs |
## Token management
- **Token refresh**: Every 20 minutes automatically
- **Token expiration**: Daily at 23:59:59 Tehran time
- **Retry logic**: Automatic retry on HTTP 5xx errors and network failures
- **Error handling**: Graceful fallback to alternative providers
## Adding a new provider
1. Add `ProviderName` enum value in `src/common/enums/provider-name.enum.ts`
2. Add provider configuration in `src/config/configuration.ts`
3. Implement provider (extend `BaseProvider` or `LegacyApiProvider`)
4. Register in `src/providers/factory/provider.factory.ts`
5. Update inquiry routing env vars — **no new HTTP routes required**
## Adding a new inquiry type
1. Add `InquiryType` enum in `src/common/enums/inquiry-type.enum.ts`
2. Create request/response DTOs with `class-validator` in `src/inquiry/dto/`
3. Add `POST /inquiries/<type>` in `InquiryController`
4. Implement service method using `ProviderOrchestratorService`
5. Configure routing and credentials in `.env`
## Security
- **JWT**: access + refresh tokens (`JWT_SECRET`, `JWT_REFRESH_SECRET`)
- **RBAC**: `@Roles()` + `RolesGuard`
- **Inquiry access**: `allowedInquiries` + `InquiryAccessGuard` (e.g. `PERSON_INQUIRY`)
- **Rate limiting**: global throttler + per-user `requestLimitPerMinute` / `requestLimitPerDay`
- **Audit**: `audit_logs` for login, user lifecycle events
- **Request logging**: All inquiries logged to MongoDB with masked sensitive data
## Error handling
The system provides comprehensive error handling:
- Network errors: Automatic retry with exponential backoff
- HTTP 5xx errors: Automatic retry (configurable attempts)
- Provider failures: Automatic fallback to alternative providers
- Validation errors: Detailed error messages with field-level validation
- Authentication errors: Automatic token refresh and retry
## Monitoring & observability
- **OpenTelemetry**: Distributed tracing support
- **Inquiry logs**: MongoDB collection with full request/response audit trail
- **Audit logs**: Security events and user actions
- **Performance metrics**: Request duration, provider response times
- **Error tracking**: Normalized error codes and messages
## Development
```bash
# Development mode with hot reload
npm run start:dev
# Build for production
npm run build
# Run tests
npm run test
# Run tests with coverage
npm run test:cov
# Lint code
npm run lint
# Format code
npm run format
```
## Environment variables
Key environment variables (see `.env.example` for complete list):
```env
# Application
NODE_ENV=development
PORT=8085
API_PREFIX=api
# Database
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
# JWT
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your-refresh-secret
JWT_REFRESH_EXPIRES_IN=7d
# Rate limiting
THROTTLE_TTL=60
THROTTLE_LIMIT=100
# Provider configuration (see Environment Configuration Guide)
```
## License
MIT

628
docs/API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,628 @@
# API Documentation
Complete API reference for the ESG Inquiry Gateway with request/response examples.
## Table of Contents
- [Authentication](#authentication)
- [Person Inquiry](#person-inquiry)
- [Real Estate Inquiry](#real-estate-inquiry)
- [Shahkar Inquiry](#shahkar-inquiry)
- [Postal Code Inquiry](#postal-code-inquiry)
- [Sheba Inquiry](#sheba-inquiry)
- [Error Responses](#error-responses)
## Base URL
```
Production: https://api.example.com
Development: http://localhost:8085
```
## Authentication
All API requests require authentication using one of these methods:
### 1. API Key (Header)
```http
X-API-Key: your-api-key-here
```
### 2. JWT Bearer Token
```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Person Inquiry
Query person information using national code and birth date.
### Endpoint
```
POST /api/inquiry/person
```
### Request Headers
```http
Content-Type: application/json
X-API-Key: your-api-key-here
```
### Request Body
```json
{
"nationalCode": "0123456789",
"birthDate": "1370/01/01"
}
```
### Request Parameters
| Field | Type | Required | Description | Example |
|-------|------|----------|-------------|---------|
| nationalCode | string | Yes | 10-digit national identification number | "0123456789" |
| birthDate | string | Yes | Birth date in Persian calendar (YYYY/MM/DD) | "1370/01/01" |
### Success Response (200 OK)
```json
{
"success": true,
"provider": "TEJARATNOU",
"trackingCode": "a3e23be5-3128-4bb6-9424-9b62b41d6381",
"message": "Person inquiry completed successfully",
"data": {
"nationalCode": "0123456789",
"birthDate": "1370/01/01",
"fullName": "علی احمدی",
"raw": {
"success": true,
"data": {
"fullName": "علی احمدی",
"fatherName": "محمد",
"birthPlace": "تهران"
}
}
},
"duration": 1250
}
```
### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| success | boolean | Whether the inquiry was successful |
| provider | string | Provider that handled the request (HAMTA, MOALLEM, TEJARATNOU) |
| trackingCode | string | Unique tracking code for this request |
| message | string | Human-readable status message |
| data | object | Inquiry result data |
| data.nationalCode | string | Queried national code |
| data.birthDate | string | Queried birth date |
| data.fullName | string | Person's full name (if found) |
| data.raw | object | Raw response from provider |
| duration | number | Request duration in milliseconds |
### cURL Example
```bash
curl -X POST https://api.example.com/api/inquiry/person \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"nationalCode": "0123456789",
"birthDate": "1370/01/01"
}'
```
### Swagger/OpenAPI Definition
```yaml
/api/inquiry/person:
post:
summary: Person Inquiry
description: Query person information using national code and birth date
tags:
- Inquiry
security:
- ApiKeyAuth: []
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- nationalCode
- birthDate
properties:
nationalCode:
type: string
pattern: '^\d{10}$'
description: 10-digit national identification number
example: "0123456789"
birthDate:
type: string
pattern: '^\d{4}/\d{2}/\d{2}$'
description: Birth date in Persian calendar (YYYY/MM/DD)
example: "1370/01/01"
responses:
'200':
description: Successful inquiry
content:
application/json:
schema:
$ref: '#/components/schemas/PersonInquiryResponse'
'400':
description: Invalid request
'401':
description: Unauthorized
'500':
description: Server error
```
## Real Estate Inquiry
Query real estate/property information.
### Endpoint
```
POST /api/inquiry/real-estate
```
### Request Body
```json
{
"nationalId": "5098961130",
"postalCode": "1349689554"
}
```
### Request Parameters
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| nationalId | string | Yes | National identification number |
| postalCode | string | Yes | Property postal code |
### Success Response (200 OK)
```json
{
"success": true,
"provider": "HAMTA",
"trackingCode": "a3e23be5-3128-4bb6-9424-9b62b41d6381",
"message": "Real estate inquiry completed successfully",
"data": {
"raw": {
"Result": {
"Result": false,
"ErrorMessage": null,
"ExternalServiceResponseDuration": 114.3783
},
"IsSucceed": true,
"TrackingCode": "YTNlMjNiZTUtMzEyOC00YmI2LTk0MjQtOWI2MmI0MWQ2Mzgx"
}
},
"duration": 1450
}
```
### cURL Example
```bash
curl -X POST https://api.example.com/api/inquiry/real-estate \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"nationalId": "5098961130",
"postalCode": "1349689554"
}'
```
## Shahkar Inquiry
Verify mobile number ownership.
### Endpoint
```
POST /api/inquiry/shahkar
```
### Request Body
```json
{
"nationalCode": "0123456789",
"mobileNo": "09123456789"
}
```
### Request Parameters
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| nationalCode | string | Yes | 10-digit national identification number |
| mobileNo | string | Yes | Mobile number (11 digits starting with 09) |
### Success Response (200 OK)
```json
{
"success": true,
"provider": "MOALLEM",
"trackingCode": "b4f34cf6-4239-5cc7-a535-ac73c52e7492",
"message": "Shahkar inquiry completed successfully",
"data": {
"raw": {
"nationalCode": "0123456789",
"mobileNo": "09123456789",
"result": "MATCH",
"soap": "<?xml version=\"1.0\" encoding=\"utf-8\"?>..."
}
},
"duration": 890
}
```
### Possible Results
| Result | Description |
|--------|-------------|
| MATCH | Mobile number belongs to the person |
| MISMATCH | Mobile number does not belong to the person |
| NOT_FOUND | No record found |
| ERROR | Service error |
### cURL Example
```bash
curl -X POST https://api.example.com/api/inquiry/shahkar \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"nationalCode": "0123456789",
"mobileNo": "09123456789"
}'
```
## Postal Code Inquiry
Lookup address information by postal code.
### Endpoint
```
POST /api/inquiry/postal-code
```
### Request Body
```json
{
"postalCode": "1234567890"
}
```
### Request Parameters
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| postalCode | string | Yes | 10-digit postal code |
### Success Response (200 OK)
```json
{
"success": true,
"provider": "MOALLEM",
"trackingCode": "c5g45dg7-5340-6dd8-b646-bd84d63f8503",
"message": "Postal code inquiry completed successfully",
"data": {
"raw": {
"province": "تهران",
"city": "تهران",
"district": "منطقه 1",
"street": "خیابان ولیعصر",
"alley": "کوچه شماره 5",
"plaque": "123"
}
},
"duration": 650
}
```
### cURL Example
```bash
curl -X POST https://api.example.com/api/inquiry/postal-code \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"postalCode": "1234567890"
}'
```
## Sheba Inquiry
Verify bank account (SHEBA/IBAN) information.
### Endpoint
```
POST /api/inquiry/sheba
```
### Request Body
```json
{
"accountOwnerType": "REAL",
"nationalId": "0123456789",
"shebaId": "IR123456789012345678901234"
}
```
### Request Parameters
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| accountOwnerType | string | Yes | Account owner type: "REAL" (person) or "LEGAL" (company) |
| nationalId | string | Conditional | Required if accountOwnerType is "REAL" |
| legalId | string | Conditional | Required if accountOwnerType is "LEGAL" |
| shebaId | string | Yes | SHEBA/IBAN number (26 characters starting with IR) |
### Success Response (200 OK)
```json
{
"success": true,
"provider": "MOALLEM",
"trackingCode": "d6h56eh8-6451-7ee9-c757-ce95e74g9614",
"message": "Sheba inquiry completed successfully",
"data": {
"raw": {
"IsSucceed": true,
"Result": {
"accountOwner": "علی احمدی",
"bankName": "بانک ملی ایران",
"branchName": "شعبه مرکزی",
"accountNumber": "1234567890",
"isActive": true
}
}
},
"duration": 1120
}
```
### cURL Example
```bash
curl -X POST https://api.example.com/api/inquiry/sheba \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \
-d '{
"accountOwnerType": "REAL",
"nationalId": "0123456789",
"shebaId": "IR123456789012345678901234"
}'
```
## Error Responses
### 400 Bad Request - Invalid Input
```json
{
"success": false,
"provider": "GATEWAY",
"trackingCode": "e7i67fi9-7562-8ff0-d868-df06f85h0725",
"message": "Validation failed",
"error": {
"code": "VALIDATION_ERROR",
"message": "nationalCode must be a 10-digit string"
},
"duration": 5
}
```
### 401 Unauthorized - Missing/Invalid API Key
```json
{
"statusCode": 401,
"message": "Unauthorized",
"error": "Unauthorized"
}
```
### 404 Not Found - Invalid Endpoint
```json
{
"statusCode": 404,
"message": "Cannot POST /api/inquiry/invalid",
"error": "Not Found"
}
```
### 429 Too Many Requests - Rate Limit Exceeded
```json
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests"
}
```
### 500 Internal Server Error - No Providers Available
```json
{
"success": false,
"provider": "GATEWAY",
"trackingCode": "f8j78gj0-8673-9gg1-e979-eg17g96i1836",
"message": "No enabled providers configured for PERSON",
"error": {
"code": "NO_PROVIDERS",
"message": "No enabled providers configured for PERSON"
},
"duration": 2
}
```
### 500 Internal Server Error - Provider Service Unavailable
```json
{
"success": false,
"provider": "GATEWAY",
"trackingCode": "g9k89hk1-9784-0hh2-f080-fh28h07j2947",
"message": "TEJARATNOU service is not available",
"error": {
"code": "PROVIDER_SERVICE_NOT_AVAILABLE",
"message": "TEJARATNOU service is not available",
"providerMessage": "Connection timeout",
"providerCode": "ETIMEDOUT"
},
"duration": 10500
}
```
### 500 Internal Server Error - All Providers Failed
```json
{
"success": false,
"provider": "GATEWAY",
"trackingCode": "h0l90il2-0895-1ii3-g191-gi39i18k3058",
"message": "TEJARATNOU failed: timeout; HAMTA failed: auth error; MOALLEM failed: network error",
"error": {
"code": "ALL_PROVIDERS_FAILED",
"message": "TEJARATNOU failed: timeout; HAMTA failed: auth error; MOALLEM failed: network error",
"providerMessage": "Connection timeout",
"providerCode": "ETIMEDOUT"
},
"duration": 25000
}
```
## Error Codes Reference
| Code | Description | HTTP Status |
|------|-------------|-------------|
| VALIDATION_ERROR | Invalid request parameters | 400 |
| NO_PROVIDERS | No providers configured for inquiry type | 500 |
| PROVIDER_SERVICE_NOT_AVAILABLE | Single provider failed | 500 |
| ALL_PROVIDERS_FAILED | All configured providers failed | 500 |
| PROVIDER_ERROR | Generic provider error | 500 |
| TIMEOUT | Request timeout | 500 |
| NETWORK_ERROR | Network connectivity issue | 500 |
| AUTH_ERROR | Authentication failed | 500 |
| UNSUPPORTED_INQUIRY | Inquiry type not supported | 400 |
## Rate Limiting
The API implements rate limiting to prevent abuse:
- **Default Limit**: 100 requests per minute per API key
- **Window**: 60 seconds (sliding window)
- **Response Header**: `X-RateLimit-Remaining` shows remaining requests
When rate limit is exceeded:
```http
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1609459200
```
## Response Time SLA
| Inquiry Type | Target Response Time | Timeout |
|--------------|---------------------|---------|
| Person | < 2 seconds | 10 seconds |
| Real Estate | < 3 seconds | 10 seconds |
| Shahkar | < 1.5 seconds | 10 seconds |
| Postal Code | < 1 second | 10 seconds |
| Sheba | < 2 seconds | 10 seconds |
## Best Practices
### 1. Always Include Tracking Code
Store the `trackingCode` from responses for troubleshooting and support requests.
### 2. Handle Errors Gracefully
Implement retry logic with exponential backoff for transient errors (5xx).
### 3. Validate Input Client-Side
Validate national codes, postal codes, and mobile numbers before sending requests.
### 4. Use Appropriate Timeouts
Set client-side timeouts slightly higher than API timeouts (e.g., 12 seconds).
### 5. Monitor Rate Limits
Track `X-RateLimit-Remaining` header and implement client-side throttling.
### 6. Log All Requests
Log request/response pairs with tracking codes for audit and debugging.
### 7. Secure API Keys
- Never expose API keys in client-side code
- Rotate keys regularly
- Use environment variables
- Implement key rotation without downtime
## Postman Collection
Import this collection to test all endpoints:
```json
{
"info": {
"name": "ESG Inquiry Gateway",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Person Inquiry",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-API-Key",
"value": "{{api_key}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"nationalCode\": \"0123456789\",\n \"birthDate\": \"1370/01/01\"\n}"
},
"url": {
"raw": "{{base_url}}/api/inquiry/person",
"host": ["{{base_url}}"],
"path": ["api", "inquiry", "person"]
}
}
}
],
"variable": [
{
"key": "base_url",
"value": "http://localhost:8085"
},
{
"key": "api_key",
"value": "your-api-key-here"
}
]
}
```
## Support
For API support:
- **Documentation**: https://docs.example.com
- **Status Page**: https://status.example.com
- **Support Email**: support@example.com
- **Emergency**: +98-21-1234-5678

View File

@@ -0,0 +1,463 @@
# Environment Configuration Guide
This guide explains all environment variables used in the ESG Inquiry Gateway system.
## Table of Contents
- [Application Settings](#application-settings)
- [Database Configuration](#database-configuration)
- [Authentication & Security](#authentication--security)
- [Provider Configuration](#provider-configuration)
- [Inquiry Routing](#inquiry-routing)
- [Provider Behavior Examples](#provider-behavior-examples)
## Application Settings
### Basic Configuration
```env
PORT=8085 # HTTP server port
NODE_ENV=development # Environment: development, production, test
```
### OpenTelemetry (Optional)
```env
OTEL_EXPORTER_OTLP_ENDPOINT=http://192.168.10.10:4318
OTEL_SERVICE_NAME=esg
OTEL_NODE_RESOURCE_DETECTORS=env,host,os
```
## Database Configuration
### MongoDB
```env
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
```
## Authentication & Security
### API Authentication
```env
API_KEY=your-secure-api-key-here # API key for external clients
JWT_SECRET=change-me-use-long-random-string # JWT signing secret
JWT_REFRESH_SECRET=change-me-refresh-secret # JWT refresh token secret
JWT_ACCESS_EXPIRES_IN=15m # Access token lifetime
JWT_REFRESH_EXPIRES_IN=7d # Refresh token lifetime
JWT_ENABLED=true # Enable/disable JWT auth
BCRYPT_SALT_ROUNDS=12 # Password hashing rounds
```
### Rate Limiting
```env
THROTTLE_TTL=60 # Time window in seconds
THROTTLE_LIMIT=100 # Max requests per window
```
## Provider Configuration
### Understanding Provider Architecture
The system has **two types of components**:
1. **Providers** (Inquiry Handlers): HAMTA, MOALLEM, TEJARATNOU
2. **Authentication Services**: AMITIS (used by HAMTA and MOALLEM)
### AMITIS Authentication Service
AMITIS is **not a provider** - it's an authentication service that provides tokens for HAMTA and MOALLEM.
```env
# AMITIS Configuration
AMITIS_BASE_URL=https://auth.services.centinsur.ir
AMITIS_LOGIN_PATH=/api/security/login
AMITIS_REFRESH_PATH=/api/security/RefreshToken
AMITIS_TOKEN_TIME_ZONE=Asia/Tehran
AMITIS_TIMEOUT=10000
AMITIS_ENABLED=true
```
**Token Management:**
- Tokens refresh every 20 minutes
- Tokens expire daily at 23:59:59 (Tehran time)
- Automatic refresh with fallback to re-login
### Provider Structure
Each provider has:
- **Global settings**: `ENABLED`, `TIMEOUT`, `MAX_RETRIES`
- **Per-inquiry settings**: `URL`, `USERNAME`, `PASSWORD`, `AUTH_METHOD`
#### Format: `PROVIDER_INQUIRY_FIELD`
Example:
```env
HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry
HAMTA_REAL_ESTATE_USERNAME=AmlakEskanHamta
HAMTA_REAL_ESTATE_PASSWORD=r#Tk5!4bj
HAMTA_REAL_ESTATE_AUTH_METHOD=AMITIS
```
### Authentication Methods
Each inquiry can use different authentication:
- **`AMITIS`**: Token-based auth via AMITIS service
- **`SOAP`**: Direct SOAP authentication (credentials in XML)
- **`OAUTH2`**: OAuth2 flow (TejaratNou)
- **`NONE`**: No authentication required
### HAMTA Provider
```env
# Global Settings
HAMTA_ENABLED=true
HAMTA_TIMEOUT=10000
HAMTA_MAX_RETRIES=3
# Person Inquiry (AMITIS auth)
HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
# Real Estate Inquiry (AMITIS auth)
HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry
HAMTA_REAL_ESTATE_USERNAME=AmlakEskanHamta
HAMTA_REAL_ESTATE_PASSWORD=r#Tk5!4bj
HAMTA_REAL_ESTATE_AUTH_METHOD=AMITIS
# Shahkar Inquiry (SOAP auth - no AMITIS)
HAMTA_SHAHKAR_URL=http://reinsure.centinsur.ir/shahkarinqOut
HAMTA_SHAHKAR_USERNAME=Hamta.Shahkar
HAMTA_SHAHKAR_PASSWORD=Y^WzP!8R5Tz
HAMTA_SHAHKAR_AUTH_METHOD=SOAP
# Sheba/Sayah Inquiry (AMITIS auth)
HAMTA_SHEBA_URL=https://api.hamta.example.com/api/inquiry/sheba
HAMTA_SHEBA_USERNAME=Hamta.sayah
HAMTA_SHEBA_PASSWORD=J$z07$bg
HAMTA_SHEBA_AUTH_METHOD=AMITIS
# Postal Code Inquiry (AMITIS auth)
HAMTA_POSTAL_CODE_URL=https://api.hamta.example.com/api/inquiry/postalCode
HAMTA_POSTAL_CODE_USERNAME=
HAMTA_POSTAL_CODE_PASSWORD=
HAMTA_POSTAL_CODE_AUTH_METHOD=AMITIS
# Legal Person Inquiry (AMITIS auth)
HAMTA_LEGAL_PERSON_URL=https://api.hamta.example.com/api/inquiry/legalPerson
HAMTA_LEGAL_PERSON_USERNAME=SabtAsnadHamta
HAMTA_LEGAL_PERSON_PASSWORD=HSY1f6?e@kSJ
HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS
```
### MOALLEM Provider
```env
# Global Settings
MOALLEM_ENABLED=true
MOALLEM_TIMEOUT=10000
MOALLEM_MAX_RETRIES=2
# Person Inquiry (SOAP auth)
MOALLEM_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
MOALLEM_PERSON_USERNAME=mo9635
MOALLEM_PERSON_PASSWORD=ciiws@sabt92
MOALLEM_PERSON_AUTH_METHOD=SOAP
# Postal Code Inquiry (AMITIS auth)
MOALLEM_POSTAL_CODE_URL=https://postalcode.services.centinsur.ir/api/cisb
MOALLEM_POSTAL_CODE_USERNAME=moallem.post
MOALLEM_POSTAL_CODE_PASSWORD=tBUCLGfVJH
MOALLEM_POSTAL_CODE_AUTH_METHOD=AMITIS
# Real Estate Inquiry (AMITIS auth)
MOALLEM_REAL_ESTATE_URL=https://api.moallem.example.com/api/inquiry/realEstate
MOALLEM_REAL_ESTATE_USERNAME=AmlakEskanMoalem
MOALLEM_REAL_ESTATE_PASSWORD=AEM@123456
MOALLEM_REAL_ESTATE_AUTH_METHOD=AMITIS
# Shahkar Inquiry (SOAP auth)
MOALLEM_SHAHKAR_URL=http://reinsure.centinsur.ir/shahkarinqOut
MOALLEM_SHAHKAR_USERNAME=ShkrMoalem1286
MOALLEM_SHAHKAR_PASSWORD=cuYYELt9
MOALLEM_SHAHKAR_AUTH_METHOD=SOAP
# Sheba/Sayah Inquiry (AMITIS auth)
MOALLEM_SHEBA_URL=https://sayah.services.centinsur.ir/api/Cisb/TatbighServiceAsync
MOALLEM_SHEBA_USERNAME=moallem.sayah
MOALLEM_SHEBA_PASSWORD=BUCLGfVJH7
MOALLEM_SHEBA_AUTH_METHOD=AMITIS
# Legal Person Inquiry (SOAP auth)
MOALLEM_LEGAL_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
MOALLEM_LEGAL_PERSON_USERNAME=
MOALLEM_LEGAL_PERSON_PASSWORD=
MOALLEM_LEGAL_PERSON_AUTH_METHOD=SOAP
```
### TEJARATNOU Provider
```env
# Global Settings
TEJARATNOU_ENABLED=true
TEJARATNOU_TIMEOUT=15000
TEJARATNOU_MAX_RETRIES=2
# OAuth2 Authentication
TEJARATNOU_AUTH_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_CLIENT_ID=api-gateway
TEJARATNOU_CLIENT_SECRET=hkld@ork123T
TEJARATNOU_USERNAME=thirdparty-silcogroup
TEJARATNOU_PASSWORD=FDHG87sdf787l764iuo
# Person Inquiry
TEJARATNOU_PERSON_URL=https://gateway.tejaratnoins.ir/api/inquiry/person
TEJARATNOU_PERSON_AUTH_METHOD=OAUTH2
```
## Inquiry Routing
Routing determines which provider handles each inquiry type and whether to use fallbacks.
### Format
```env
{INQUIRY_TYPE}_DEFAULT_PROVIDER=PROVIDER_NAME
{INQUIRY_TYPE}_FALLBACK_ENABLED=true|false
{INQUIRY_TYPE}_FALLBACK_PROVIDERS=PROVIDER1,PROVIDER2
```
### Available Inquiry Types
- `PERSON` - Person/Civil registration inquiry
- `REAL_ESTATE` - Real estate/property inquiry
- `SHAHKAR` - Mobile number verification
- `POSTAL_CODE` - Postal code lookup
- `SHEBA` - Bank account verification
- `LEGAL_PERSON` - Legal entity inquiry
- `CAR_PLATE` - Vehicle plate inquiry
### Example Configurations
#### Person Inquiry (Single Provider)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=false
PERSON_FALLBACK_PROVIDERS=
```
**Behavior**: Only TEJARATNOU is used. If it fails, request fails.
#### Person Inquiry (With Fallback)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM
```
**Behavior**:
1. Try TEJARATNOU first
2. If fails, try HAMTA
3. If fails, try MOALLEM
4. If all fail, return error
#### Real Estate Inquiry
```env
REAL_ESTATE_DEFAULT_PROVIDER=HAMTA
REAL_ESTATE_FALLBACK_ENABLED=false
REAL_ESTATE_FALLBACK_PROVIDERS=
```
#### Shahkar Inquiry
```env
SHAHKAR_DEFAULT_PROVIDER=MOALLEM
SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS=
```
## Provider Behavior Examples
### Scenario 1: All Providers Enabled
```env
HAMTA_ENABLED=true
MOALLEM_ENABLED=true
TEJARATNOU_ENABLED=true
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=false
```
**Result**: Only TEJARATNOU is used for person inquiries, even though others are enabled.
### Scenario 2: Default Provider Disabled
```env
HAMTA_ENABLED=false
MOALLEM_ENABLED=true
TEJARATNOU_ENABLED=true
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=TEJARATNOU,MOALLEM
```
**Result**:
- HAMTA is skipped (disabled)
- TEJARATNOU is tried first (first enabled fallback)
- MOALLEM is tried if TEJARATNOU fails
### Scenario 3: No Providers Available
```env
HAMTA_ENABLED=false
MOALLEM_ENABLED=false
TEJARATNOU_ENABLED=false
PERSON_DEFAULT_PROVIDER=TEJARATNOU
```
**Result**: API returns error:
```json
{
"success": false,
"provider": "GATEWAY",
"message": "No enabled providers configured for PERSON",
"error": {
"code": "NO_PROVIDERS",
"message": "No enabled providers configured for PERSON"
}
}
```
### Scenario 4: All Providers Fail
```env
HAMTA_ENABLED=true
MOALLEM_ENABLED=true
TEJARATNOU_ENABLED=true
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM
```
**Result**: If all three providers fail, API returns:
```json
{
"success": false,
"provider": "GATEWAY",
"message": "TEJARATNOU failed: timeout; HAMTA failed: auth error; MOALLEM failed: network error",
"error": {
"code": "ALL_PROVIDERS_FAILED",
"message": "Combined error messages from all providers"
}
}
```
## Production Recommendations
### High Availability Setup
```env
# Enable all providers
HAMTA_ENABLED=true
MOALLEM_ENABLED=true
TEJARATNOU_ENABLED=true
# Use fallbacks for critical inquiries
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM
REAL_ESTATE_DEFAULT_PROVIDER=HAMTA
REAL_ESTATE_FALLBACK_ENABLED=true
REAL_ESTATE_FALLBACK_PROVIDERS=MOALLEM
```
### Performance-Optimized Setup
```env
# Enable only primary providers
HAMTA_ENABLED=true
MOALLEM_ENABLED=false
TEJARATNOU_ENABLED=true
# No fallbacks for faster response
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_FALLBACK_ENABLED=false
REAL_ESTATE_DEFAULT_PROVIDER=HAMTA
REAL_ESTATE_FALLBACK_ENABLED=false
```
### Cost-Optimized Setup
```env
# Enable only necessary providers
HAMTA_ENABLED=true
MOALLEM_ENABLED=false
TEJARATNOU_ENABLED=false
# Single provider per inquiry type
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_ENABLED=false
REAL_ESTATE_DEFAULT_PROVIDER=HAMTA
REAL_ESTATE_FALLBACK_ENABLED=false
```
## Security Best Practices
1. **Never commit `.env` to git** - Use `.env.example` as template
2. **Rotate credentials regularly** - Update passwords every 90 days
3. **Use strong secrets** - Generate random strings for JWT secrets
4. **Limit API key distribution** - One key per client application
5. **Enable rate limiting** - Protect against abuse
6. **Monitor failed authentications** - Alert on repeated failures
7. **Use HTTPS in production** - Never send credentials over HTTP
## Troubleshooting
### Provider Not Working
1. Check if provider is enabled: `{PROVIDER}_ENABLED=true`
2. Verify credentials are correct
3. Check if inquiry type is configured for that provider
4. Verify network connectivity to provider URL
5. Check logs for authentication errors
### No Providers Available Error
1. Verify at least one provider is enabled
2. Check `{INQUIRY}_DEFAULT_PROVIDER` is set correctly
3. Ensure the default provider supports the inquiry type
4. Verify provider credentials are configured
### Authentication Failures
1. Check AMITIS service is enabled and reachable
2. Verify credentials in `{PROVIDER}_{INQUIRY}_USERNAME/PASSWORD`
3. Check if tokens are expiring (should auto-refresh)
4. Verify system time is correct (affects token expiration)
### Performance Issues
1. Reduce `MAX_RETRIES` for faster failures
2. Decrease `TIMEOUT` values
3. Disable fallbacks if not needed
4. Enable only required providers
5. Check network latency to provider services
## Migration from Old Format
### Old Format (Deprecated)
```env
AMITIS_HAMTA_PERSON_USERNAME=pa6476
AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92
```
### New Format (Current)
```env
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
```
**Key Changes:**
- Removed `AMITIS_` prefix from provider credentials
- Added `AUTH_METHOD` to specify authentication type
- Added per-inquiry `URL` configuration
- AMITIS is now a separate authentication service, not a provider

9
nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [{ "include": "public/**/*", "outDir": "dist" }]
}
}

12359
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
package.json Normal file
View File

@@ -0,0 +1,91 @@
{
"name": "inquiry-gateway",
"version": "1.0.0",
"description": "Unified inquiry gateway for external service providers",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^11.0.2",
"@nestjs/mongoose": "^10.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.3.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.218.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-logs": "^0.218.0",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@opentelemetry/winston-transport": "^0.28.0",
"axios": "^1.7.9",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.5",
"express": "^5.2.1",
"mongoose": "^8.9.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

43
src/app.module.ts Normal file
View File

@@ -0,0 +1,43 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { CommonModule } from './common/common.module';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { AppConfigModule } from './config/config.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { AuditModule } from './audit/audit.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
import { LoggingModule } from './logging/logging.module';
import { ProvidersModule } from './providers/providers.module';
import { InquiryModule } from './inquiry/inquiry.module';
import { DatabaseSeederModule } from './database/database-seeder.module';
/**
* Root application module — wires all feature and infrastructure modules.
*/
@Module({
imports: [
AppConfigModule,
CommonModule,
AuditModule,
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongodb.uri'),
}),
}),
RateLimitModule,
LoggingModule,
AuthModule,
UsersModule,
DatabaseSeederModule,
ProvidersModule,
InquiryModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}

12
src/audit/audit.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuditService } from './audit.service';
import { AuditLog, AuditLogSchema } from './schemas/audit-log.schema';
@Global()
@Module({
imports: [MongooseModule.forFeature([{ name: AuditLog.name, schema: AuditLogSchema }])],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { AuditEvent } from '../common/enums/audit-event.enum';
import { AuditLog, AuditLogDocument } from './schemas/audit-log.schema';
export interface AuditContext {
userId?: string;
actorId?: string;
username?: string;
ip?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
}
/**
* Central audit writer — all security-sensitive actions flow through here.
*/
@Injectable()
export class AuditService {
constructor(
@InjectModel(AuditLog.name)
private readonly auditModel: Model<AuditLogDocument>,
) {}
async log(event: AuditEvent, context: AuditContext = {}): Promise<void> {
await this.auditModel.create({
event,
userId: context.userId ? new Types.ObjectId(context.userId) : undefined,
actorId: context.actorId ? new Types.ObjectId(context.actorId) : undefined,
username: context.username,
ip: context.ip,
userAgent: context.userAgent,
metadata: context.metadata ?? {},
});
}
}

View File

@@ -0,0 +1,37 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
import { AuditEvent } from '../../common/enums/audit-event.enum';
export type AuditLogDocument = HydratedDocument<AuditLog>;
/**
* Immutable security audit trail — separate from inquiry_logs (business audit).
*/
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'audit_logs' })
export class AuditLog {
@Prop({ required: true, enum: AuditEvent, index: true })
event!: AuditEvent;
@Prop({ type: Types.ObjectId, ref: 'User', index: true })
userId?: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: 'User' })
actorId?: Types.ObjectId;
@Prop()
username?: string;
@Prop()
ip?: string;
@Prop()
userAgent?: string;
@Prop({ type: Object, default: {} })
metadata!: Record<string, unknown>;
}
export const AuditLogSchema = SchemaFactory.createForClass(AuditLog);
AuditLogSchema.index({ createdAt: -1 });
AuditLogSchema.index({ event: 1, createdAt: -1 });

View File

@@ -0,0 +1,69 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { AuthTokensDto } from './dto/auth-tokens.dto';
import { ProfileDto } from './dto/profile.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@ApiOperation({ summary: 'Login with username and password' })
@ApiResponse({ status: 200, type: AuthTokensDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@ApiResponse({ status: 403, description: 'Inactive or blocked account' })
async login(@Body() dto: LoginDto, @Req() req: Request): Promise<AuthTokensDto> {
return this.authService.login(dto, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 200, type: AuthTokensDto })
async refresh(@Body() dto: RefreshTokenDto, @Req() req: Request): Promise<AuthTokensDto> {
return this.authService.refresh(dto.refreshToken, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
@ApiResponse({ status: 200, description: 'Logged out successfully' })
async logout(
@CurrentUser() user: AuthenticatedUser,
@Req() req: Request,
): Promise<{ message: string }> {
await this.authService.logout(user.id, {
ip: req.ip,
userAgent: req.headers['user-agent'],
});
return { message: 'Logged out successfully' };
}
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({ status: 200, type: ProfileDto })
async profile(@CurrentUser() user: AuthenticatedUser): Promise<ProfileDto> {
return this.authService.getProfile(user.id);
}
}

54
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PasswordService } from './services/password.service';
import { TokenService } from './services/token.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { InquiryAccessGuard } from './guards/inquiry-access.guard';
import { ApiKeyGuard } from './guards/api-key.guard';
/**
* Authentication & authorization — JWT, RBAC, inquiry access.
* ApiKeyGuard retained for future M2M auth alongside JWT.
*/
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt-access' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('auth.jwtSecret'),
}),
}),
forwardRef(() => UsersModule),
],
controllers: [AuthController],
providers: [
AuthService,
PasswordService,
TokenService,
JwtStrategy,
JwtAuthGuard,
RolesGuard,
InquiryAccessGuard,
ApiKeyGuard,
],
exports: [
AuthService,
PasswordService,
TokenService,
JwtAuthGuard,
RolesGuard,
InquiryAccessGuard,
ApiKeyGuard,
PassportModule,
JwtModule,
],
})
export class AuthModule {}

158
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuditEvent } from '../common/enums/audit-event.enum';
import { AuditService } from '../audit/audit.service';
import { UsersService, RequestContext } from '../users/users.service';
import { PasswordService } from './services/password.service';
import { TokenService } from './services/token.service';
import { LoginDto } from './dto/login.dto';
import { AuthTokensDto } from './dto/auth-tokens.dto';
import { ProfileDto } from './dto/profile.dto';
import { UserMapper } from '../users/mappers/user.mapper';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly passwordService: PasswordService,
private readonly tokenService: TokenService,
private readonly auditService: AuditService,
) {}
async login(dto: LoginDto, context: RequestContext = {}): Promise<AuthTokensDto> {
const user = await this.usersService.findByUsername(dto.username, true);
if (!user) {
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
username: dto.username,
ip: context.ip,
userAgent: context.userAgent,
metadata: { reason: 'invalid_credentials' },
});
throw new UnauthorizedException('Invalid credentials');
}
if (!user.isActive) {
await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context);
throw new ForbiddenException('Account is inactive');
}
if (user.isBlocked) {
await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context);
throw new ForbiddenException('Account is blocked');
}
const valid = await this.passwordService.compare(dto.password, user.password);
if (!valid) {
await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context);
throw new UnauthorizedException('Invalid credentials');
}
const tokens = await this.issueTokens(user);
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
await this.usersService.recordLogin(user._id.toString());
await this.auditService.log(AuditEvent.LOGIN, {
userId: user._id.toString(),
username: user.username,
ip: context.ip,
userAgent: context.userAgent,
});
return tokens;
}
async refresh(refreshToken: string, context: RequestContext = {}): Promise<AuthTokensDto> {
let payload: { sub: string };
try {
payload = this.tokenService.verifyRefreshToken(refreshToken);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken);
if (!valid) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(payload.sub);
if (!user.isActive) {
throw new ForbiddenException('Account is inactive');
}
if (user.isBlocked) {
throw new ForbiddenException('Account is blocked');
}
const tokens = await this.issueTokens(user);
await this.usersService.saveRefreshToken(user._id.toString(), tokens.refreshToken);
await this.auditService.log(AuditEvent.LOGIN, {
userId: user._id.toString(),
username: user.username,
ip: context.ip,
userAgent: context.userAgent,
metadata: { type: 'refresh' },
});
return tokens;
}
async logout(userId: string, context: RequestContext = {}): Promise<void> {
await this.usersService.clearRefreshToken(userId);
const user = await this.usersService.findById(userId);
await this.auditService.log(AuditEvent.LOGOUT, {
userId,
username: user.username,
ip: context.ip,
userAgent: context.userAgent,
});
}
async getProfile(userId: string): Promise<ProfileDto> {
const user = await this.usersService.findById(userId);
return UserMapper.toProfileDto(user);
}
private async issueTokens(user: {
_id: { toString(): string };
role: JwtPayload['role'];
clientType: JwtPayload['clientType'];
appName: string;
}): Promise<AuthTokensDto> {
const payload: JwtPayload = {
sub: user._id.toString(),
role: user.role,
clientType: user.clientType,
appName: user.appName,
};
const pair = this.tokenService.generateTokenPair(payload);
return {
accessToken: pair.accessToken,
refreshToken: pair.refreshToken,
tokenType: 'Bearer',
expiresIn: 900,
};
}
private async auditFailedLogin(
userId: string,
username: string,
reason: string,
context: RequestContext,
): Promise<void> {
await this.auditService.log(AuditEvent.LOGIN_FAILED, {
userId,
username,
ip: context.ip,
userAgent: context.userAgent,
metadata: { reason },
});
}
}

View File

@@ -0,0 +1,4 @@
export const ROLES_KEY = 'roles';
export const INQUIRY_ACCESS_KEY = 'inquiryAccess';
export const JWT_ACCESS_STRATEGY = 'jwt-access';
export const JWT_REFRESH_STRATEGY = 'jwt-refresh';

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
/**
* Injects the authenticated user from the request (set by JwtStrategy).
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
const request = ctx.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
return request.user;
},
);

View File

@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
/**
* Declares which inquiry type an endpoint requires.
* InquiryAccessGuard reads this metadata and checks user.allowedInquiries.
*/
export const InquiryAccess = (inquiryType: InquiryType) =>
SetMetadata(INQUIRY_ACCESS_KEY, inquiryType);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../common/enums/role.enum';
import { ROLES_KEY } from '../constants/auth.constants';
/** Restrict route to one or more RBAC roles. Use with RolesGuard. */
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class AuthTokensDto {
@ApiProperty()
accessToken!: string;
@ApiProperty()
refreshToken!: string;
@ApiProperty({ example: 'Bearer' })
tokenType!: string;
@ApiProperty({ example: 900, description: 'Access token lifetime in seconds' })
expiresIn!: number;
}

15
src/auth/dto/login.dto.ts Normal file
View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty({ example: 'admin' })
@IsString()
@IsNotEmpty()
username!: string;
@ApiProperty({ example: 'SecureP@ssw0rd' })
@IsString()
@IsNotEmpty()
@MinLength(8)
password!: string;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
export class ProfileDto {
@ApiProperty()
id!: string;
@ApiProperty()
fullName!: string;
@ApiProperty()
username!: string;
@ApiProperty()
email!: string;
@ApiPropertyOptional()
mobile?: string;
@ApiProperty({ enum: Role })
role!: Role;
@ApiProperty({ enum: ClientType })
clientType!: ClientType;
@ApiProperty()
appName!: string;
@ApiProperty()
clientName!: string;
@ApiProperty()
isActive!: boolean;
@ApiProperty()
isBlocked!: boolean;
@ApiProperty({ type: [String] })
allowedInquiries!: string[];
@ApiProperty()
requestLimitPerMinute!: number;
@ApiProperty()
requestLimitPerDay!: number;
@ApiProperty()
totalRequests!: number;
@ApiPropertyOptional()
lastLoginAt?: Date;
@ApiPropertyOptional()
passwordChangedAt?: Date;
@ApiProperty()
createdAt!: Date;
@ApiProperty()
updatedAt!: Date;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty({ description: 'Refresh token from login response' })
@IsString()
@IsNotEmpty()
refreshToken!: string;
}

View File

@@ -0,0 +1,34 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { API_KEY_HEADER } from '../../common/constants/app.constants';
/**
* API key authentication for inquiry endpoints.
* Expects x-api-key header matching configured API_KEY.
*/
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const apiKey = request.headers[API_KEY_HEADER] as string | undefined;
const expected = this.configService.get<string>('auth.apiKey');
if (!expected) {
throw new UnauthorizedException('API key authentication is not configured');
}
if (!apiKey || apiKey !== expected) {
throw new UnauthorizedException('Invalid or missing API key');
}
return true;
}
}

View File

@@ -0,0 +1,45 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { ADMIN_ROLES } from '../../common/enums/role.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
/**
* Validates that the user may call a specific inquiry endpoint.
* ADMIN / SUPER_ADMIN bypass — external USER clients need allowedInquiries.
*/
@Injectable()
export class InquiryAccessGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredInquiry = this.reflector.getAllAndOverride<InquiryType>(INQUIRY_ACCESS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredInquiry) {
return true;
}
const request = context.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
}
if (ADMIN_ROLES.includes(user.role)) {
return true;
}
const inquiryKey = requiredInquiry as string;
if (!user.allowedInquiries.includes(inquiryKey)) {
throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`);
}
return true;
}
}

View File

@@ -0,0 +1,21 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
/**
* Protects routes with Passport JWT access strategy.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard(JWT_ACCESS_STRATEGY) {
override handleRequest<TUser>(
err: Error | null,
user: TUser | false,
_info: unknown,
_context: ExecutionContext,
): TUser {
if (err || !user) {
throw err ?? new UnauthorizedException('Unauthorized');
}
return user;
}
}

View File

@@ -0,0 +1,34 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { ROLES_KEY } from '../constants/auth.constants';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { Role } from '../../common/enums/role.enum';
/**
* Enforces @Roles() metadata against the authenticated user's role.
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles?.length) {
return true;
}
const request = context.switchToHttp().getRequest<Request & { user: AuthenticatedUser }>();
const user = request.user;
if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException('Insufficient role permissions');
}
return true;
}
}

View File

@@ -0,0 +1,18 @@
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
/**
* User object attached to the request after JWT validation.
* Subset of the User document — avoids loading sensitive fields on every request.
*/
export interface AuthenticatedUser {
id: string;
username: string;
email: string;
role: Role;
clientType: ClientType;
appName: string;
allowedInquiries: string[];
requestLimitPerMinute: number;
requestLimitPerDay: number;
}

View File

@@ -0,0 +1,13 @@
import { ClientType } from '../../common/enums/client-type.enum';
import { Role } from '../../common/enums/role.enum';
/**
* Claims embedded in access tokens.
* Keep minimal — load full user from DB when needed.
*/
export interface JwtPayload {
sub: string;
role: Role;
clientType: ClientType;
appName: string;
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
/**
* Password hashing — single responsibility for bcrypt operations.
* Future: argon2, password history, complexity rules.
*/
@Injectable()
export class PasswordService {
constructor(private readonly configService: ConfigService) {}
async hash(plain: string): Promise<string> {
const rounds = this.configService.get<number>('auth.bcryptSaltRounds', 12);
return bcrypt.hash(plain, rounds);
}
async compare(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
/**
* JWT issuance — access and refresh tokens with separate secrets.
*/
@Injectable()
export class TokenService {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
generateAccessToken(payload: JwtPayload): string {
return this.jwtService.sign(payload as object, {
secret: this.configService.getOrThrow<string>('auth.jwtSecret'),
expiresIn: this.configService.get<string>('auth.jwtAccessExpiresIn', '15m') as `${number}m`,
});
}
generateRefreshToken(payload: Pick<JwtPayload, 'sub'>): string {
return this.jwtService.sign(
{ sub: payload.sub },
{
secret: this.configService.getOrThrow<string>('auth.jwtRefreshSecret'),
expiresIn: this.configService.get<string>('auth.jwtRefreshExpiresIn', '7d') as `${number}d`,
},
);
}
generateTokenPair(payload: JwtPayload): TokenPair {
return {
accessToken: this.generateAccessToken(payload),
refreshToken: this.generateRefreshToken({ sub: payload.sub }),
};
}
verifyRefreshToken(token: string): Pick<JwtPayload, 'sub'> {
return this.jwtService.verify(token, {
secret: this.configService.getOrThrow<string>('auth.jwtRefreshSecret'),
});
}
}

View File

@@ -0,0 +1,48 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { UsersService } from '../../users/users.service';
/**
* Validates access JWT and attaches a slim user object to the request.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY) {
constructor(
configService: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('auth.jwtSecret'),
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
const user = await this.usersService.findById(payload.sub);
if (!user.isActive) {
throw new UnauthorizedException('Account is inactive');
}
if (user.isBlocked) {
throw new UnauthorizedException('Account is blocked');
}
return {
id: user._id.toString(),
username: user.username,
email: user.email,
role: user.role,
clientType: user.clientType,
appName: user.appName,
allowedInquiries: user.allowedInquiries,
requestLimitPerMinute: user.requestLimitPerMinute,
requestLimitPerDay: user.requestLimitPerDay,
};
}
}

26
src/cli/cli.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { AppConfigModule } from '../config/config.module';
import { PasswordService } from '../auth/services/password.service';
import { User, UserSchema } from '../users/schemas/user.schema';
import { CreateSuperAdminService } from './create-super-admin.service';
/**
* Minimal CLI context — only DB + password hashing.
* Does not load AuthModule (AuthService, JWT, AuditService, etc.).
*/
@Module({
imports: [
AppConfigModule,
MongooseModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongodb.uri'),
}),
}),
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
providers: [CreateSuperAdminService, PasswordService],
})
export class CliModule {}

View File

@@ -0,0 +1,66 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { randomBytes } from 'crypto';
import { ClientType } from '../common/enums/client-type.enum';
import { Role } from '../common/enums/role.enum';
import { PasswordService } from '../auth/services/password.service';
import { User, UserDocument } from '../users/schemas/user.schema';
import { UserMapper } from '../users/mappers/user.mapper';
import { UserResponseDto } from '../users/dto/user-response.dto';
export interface CreateSuperAdminInput {
fullName: string;
username: string;
email: string;
password: string;
}
@Injectable()
export class CreateSuperAdminService {
private readonly logger = new Logger(CreateSuperAdminService.name);
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly passwordService: PasswordService,
) {}
async create(input: CreateSuperAdminInput): Promise<UserResponseDto> {
if (input.password.length < 8) {
throw new ConflictException('Password must be at least 8 characters');
}
const username = input.username.toLowerCase().trim();
const email = input.email.toLowerCase().trim();
const existing = await this.userModel.findOne({ $or: [{ username }, { email }] });
if (existing) {
throw new ConflictException('Username or email already exists');
}
const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN });
if (superAdminExists) {
this.logger.warn('A SUPER_ADMIN already exists; creating another.');
}
const passwordHash = await this.passwordService.hash(input.password);
const user = await this.userModel.create({
fullName: input.fullName.trim(),
username,
email,
password: passwordHash,
role: Role.SUPER_ADMIN,
clientType: ClientType.INTERNAL,
appName: 'inquiry-gateway',
clientName: 'Internal',
isActive: true,
isBlocked: false,
allowedInquiries: [],
apiKey: `esg_${randomBytes(32).toString('hex')}`,
passwordChangedAt: new Date(),
});
return UserMapper.toResponseDto(user);
}
}

View File

@@ -0,0 +1,72 @@
/**
* CLI entry point — creates the initial SUPER_ADMIN user.
* Run: npm run cli:create-super-admin
*/
import { NestFactory } from '@nestjs/core';
import * as readline from 'readline';
import { CliModule } from './cli.module';
import { CreateSuperAdminService } from './create-super-admin.service';
function prompt(rl: readline.Interface, question: string, hidden = false): Promise<string> {
return new Promise((resolve) => {
if (!hidden) {
rl.question(question, resolve);
return;
}
const stdin = process.stdin;
const onData = (char: Buffer): void => {
const c = char.toString('utf8');
switch (c) {
case '\n':
case '\r':
case '\u0004':
stdin.pause();
break;
default:
process.stdout.write('\x1B[2K\x1B[200D' + question + '*'.repeat(rl.line.length));
break;
}
};
stdin.on('data', onData);
rl.question(question, (answer) => {
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(answer);
});
});
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.createApplicationContext(CliModule, {
logger: ['error', 'warn', 'log'],
});
const service = app.get(CreateSuperAdminService);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
console.log('\n=== Create Super Admin ===\n');
const fullName = (await prompt(rl, 'Full name: ')).trim();
const username = (await prompt(rl, 'Username: ')).trim();
const email = (await prompt(rl, 'Email: ')).trim();
const password = (await prompt(rl, 'Password (min 8 chars): ', true)).trim();
rl.close();
const user = await service.create({ fullName, username, email, password });
console.log('\nSuper admin created successfully.');
console.log(` ID: ${user.id}`);
console.log(` Username: ${user.username}`);
console.log(` Email: ${user.email}`);
console.log(` Role: ${user.role}\n`);
} catch (error) {
rl.close();
console.error('\nFailed:', error instanceof Error ? error.message : error);
process.exitCode = 1;
} finally {
await app.close();
}
}
bootstrap();

View File

@@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { RequestIdInterceptor } from './interceptors/request-id.interceptor';
/**
* Shared cross-cutting concerns: filters, interceptors, helpers.
* Marked @Global so feature modules need not re-import utilities.
*/
@Global()
@Module({
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_INTERCEPTOR, useClass: RequestIdInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
exports: [],
})
export class CommonModule {}

View File

@@ -0,0 +1,3 @@
export const REQUEST_ID_HEADER = 'x-request-id';
export const API_KEY_HEADER = 'x-api-key';
export const TRACKING_CODE_PREFIX = 'INQ';

View File

@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const RequestId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest<{ requestId?: string }>();
return request.requestId ?? 'unknown';
},
);

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { NormalizedErrorDto } from './normalized-error.dto';
/**
* Unified inquiry response envelope.
* Every inquiry endpoint MUST return this structure.
*/
export class BaseInquiryResponseDto<T = Record<string, unknown>> {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: 'HAMTA' })
provider!: string;
@ApiProperty({ example: 'INQ-20250525-ABC123' })
trackingCode!: string;
@ApiPropertyOptional({ example: 'Inquiry completed successfully' })
message?: string;
@ApiPropertyOptional()
data?: T;
@ApiPropertyOptional({ type: NormalizedErrorDto })
error?: NormalizedErrorDto;
@ApiProperty({ example: 342, description: 'Duration in milliseconds' })
duration!: number;
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* Standardized error shape returned to API consumers
* regardless of upstream provider error formats.
*/
export class NormalizedErrorDto {
@ApiProperty({ example: 'PROVIDER_TIMEOUT' })
code!: string;
@ApiProperty({ example: 'Provider request timed out' })
message!: string;
@ApiPropertyOptional({ example: 'سرویس در دسترس نیست' })
providerMessage?: string;
@ApiPropertyOptional({ example: '503' })
providerCode?: string;
}

View File

@@ -0,0 +1,10 @@
/** Security-relevant events written to the audit log collection. */
export enum AuditEvent {
LOGIN = 'LOGIN',
LOGIN_FAILED = 'LOGIN_FAILED',
LOGOUT = 'LOGOUT',
USER_CREATED = 'USER_CREATED',
PASSWORD_RESET = 'PASSWORD_RESET',
USER_BLOCKED = 'USER_BLOCKED',
USER_UNBLOCKED = 'USER_UNBLOCKED',
}

View File

@@ -0,0 +1,8 @@
/**
* Distinguishes internal operators from external API consumers.
* Used in JWT payload and for future multi-tenant / provider routing rules.
*/
export enum ClientType {
INTERNAL = 'INTERNAL',
EXTERNAL = 'EXTERNAL',
}

View File

@@ -0,0 +1,5 @@
export enum InquiryStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE',
PARTIAL = 'PARTIAL',
}

View File

@@ -0,0 +1,13 @@
/**
* Canonical inquiry types exposed via unified gateway endpoints.
* Add new types here as new POST /inquiries/* routes are introduced.
*/
export enum InquiryType {
PERSON = 'PERSON_INQUIRY',
REAL_ESTATE = 'REAL_ESTATE_INQUIRY',
SHEBA = 'SHEBA_INQUIRY',
SHAHKAR = 'SHAHKAR_INQUIRY',
POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
CAR_PLATE = 'CAR_PLATE_INQUIRY',
}

View File

@@ -0,0 +1,10 @@
/**
* Registered provider identifiers.
* Used by factory, configuration, and response normalization.
*/
export enum ProviderName {
HAMTA = 'HAMTA',
MOALLEM = 'MOALLEM',
TEJARATNOU = 'TEJARATNOU',
AMITIS = 'AMITIS',
}

View File

@@ -0,0 +1,12 @@
/**
* Role-based access control (RBAC) roles for the unified users system.
* SUPER_ADMIN > ADMIN > USER in privilege hierarchy.
*/
export enum Role {
SUPER_ADMIN = 'SUPER_ADMIN',
ADMIN = 'ADMIN',
USER = 'USER',
}
/** Roles allowed to manage other users (create, block, reset password). */
export const ADMIN_ROLES: Role[] = [Role.SUPER_ADMIN, Role.ADMIN];

View File

@@ -0,0 +1,12 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export class InquiryException extends HttpException {
constructor(
message: string,
public readonly normalizedError: NormalizedErrorDto,
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
) {
super({ message, error: normalizedError }, status);
}
}

View File

@@ -0,0 +1,11 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export class ProviderException extends HttpException {
constructor(
public readonly normalizedError: NormalizedErrorDto,
status: HttpStatus = HttpStatus.BAD_GATEWAY,
) {
super(normalizedError, status);
}
}

View File

@@ -0,0 +1,77 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { ProviderException } from '../exceptions/provider.exception';
/**
* Global exception filter — normalizes all errors into BaseInquiryResponseDto
* when request context includes tracking metadata.
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { trackingCode?: string }>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const normalizedError = this.extractNormalizedError(exception);
const trackingCode = request.trackingCode ?? 'UNKNOWN';
this.logger.error(
`requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`,
exception instanceof Error ? exception.stack : undefined,
);
const body: BaseInquiryResponseDto = {
success: false,
provider: 'GATEWAY',
trackingCode,
message: normalizedError.message,
error: normalizedError,
duration: 0,
};
response.status(status).json(body);
}
private extractNormalizedError(exception: unknown): NormalizedErrorDto {
if (exception instanceof ProviderException) {
return exception.normalizedError;
}
if (exception instanceof HttpException) {
const res = exception.getResponse();
if (typeof res === 'object' && res !== null && 'error' in res) {
return (res as { error: NormalizedErrorDto }).error;
}
const message =
typeof res === 'string'
? res
: (res as { message?: string | string[] }).message;
return {
code: 'HTTP_ERROR',
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message),
};
}
return {
code: 'INTERNAL_ERROR',
message: exception instanceof Error ? exception.message : 'Unexpected error',
};
}
}

View File

@@ -0,0 +1,132 @@
const JALALI_BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097,
2192, 2262, 2324, 2394, 2456, 3178,
];
export function jalaliDateToGregorianDate(jalaliDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(jalaliDate);
if (!match) {
throw new Error('birthDate must be a Jalali date in YYYY-MM-DD format');
}
const jy = Number(match[1]);
const jm = Number(match[2]);
const jd = Number(match[3]);
if (!isValidJalaliDate(jy, jm, jd)) {
throw new Error('birthDate is not a valid Jalali date');
}
const { gy, gm, gd } = jalaliToGregorian(jy, jm, jd);
return `${gy}-${pad2(gm)}-${pad2(gd)}`;
}
function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
if (jm < 1 || jm > 12 || jd < 1) return false;
if (jm <= 6) return jd <= 31;
if (jm <= 11) return jd <= 30;
return jd <= (isJalaliLeapYear(jy) ? 30 : 29);
}
function isJalaliLeapYear(jy: number): boolean {
return jalCal(jy).leap === 0;
}
function jalaliToGregorian(
jy: number,
jm: number,
jd: number,
): { gy: number; gm: number; gd: number } {
const gy = jalCal(jy).gy;
const march = jalCal(jy).march;
const daysInGregorianMonth = [
31,
isGregorianLeapYear(gy) ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let dayOfYear = jd - 1;
if (jm <= 6) {
dayOfYear += (jm - 1) * 31;
} else {
dayOfYear += 186 + (jm - 7) * 30;
}
let gd = march + dayOfYear;
let gm = 3;
let targetGy = gy;
while (gd > daysInGregorianMonth[gm - 1]) {
gd -= daysInGregorianMonth[gm - 1];
gm += 1;
if (gm > 12) {
gm = 1;
targetGy += 1;
daysInGregorianMonth[1] = isGregorianLeapYear(targetGy) ? 29 : 28;
}
}
return { gy: targetGy, gm, gd };
}
function jalCal(jy: number): { leap: number; gy: number; march: number } {
const breaksLength = JALALI_BREAKS.length;
const gy = jy + 621;
let leapJ = -14;
let jp = JALALI_BREAKS[0];
let jump = 0;
if (jy < jp || jy >= JALALI_BREAKS[breaksLength - 1]) {
throw new Error('Jalali year is out of supported range');
}
for (let i = 1; i < breaksLength; i += 1) {
const jm = JALALI_BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
let n = jy - jp;
leapJ += div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (jump - n < 6) {
n = n - jump + div(jump + 4, 33) * 33;
}
let leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function isGregorianLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
function div(a: number, b: number): number {
return Math.floor(a / b);
}
function mod(a: number, b: number): number {
return a - Math.floor(a / b) * b;
}
function pad2(value: number): string {
return String(value).padStart(2, '0');
}

View File

@@ -0,0 +1,33 @@
const SENSITIVE_KEYS = [
'password',
'secret',
'secretKey',
'token',
'authorization',
'nationalCode',
];
/**
* Masks sensitive fields before persisting inquiry logs.
*/
export function maskPayload<T extends Record<string, unknown>>(
payload: T,
): Record<string, unknown> {
return maskObject({ ...payload }) as Record<string, unknown>;
}
function maskObject(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k.toLowerCase()))) {
result[key] = '***MASKED***';
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
result[key] = maskObject(value as Record<string, unknown>);
} else {
result[key] = value;
}
}
return result;
}

View File

@@ -0,0 +1,51 @@
import { Logger } from '@nestjs/common';
export interface RequestLogContext {
requestId: string;
trackingCode?: string;
provider?: string;
inquiryType?: string;
durationMs?: number;
success?: boolean;
}
/**
* Structured console logging for inquiry lifecycle events.
* Complements MongoDB persistence in LoggingModule.
*/
export class RequestLogger {
private readonly logger: Logger;
constructor(context: string) {
this.logger = new Logger(context);
}
logStart(ctx: RequestLogContext, message: string): void {
this.logger.log(this.format(ctx, message));
}
logSuccess(ctx: RequestLogContext, message: string): void {
this.logger.log(this.format({ ...ctx, success: true }, message));
}
logFailure(ctx: RequestLogContext, message: string, error?: unknown): void {
this.logger.error(
this.format({ ...ctx, success: false }, message),
error instanceof Error ? error.stack : undefined,
);
}
private format(ctx: RequestLogContext, message: string): string {
const parts = [
`requestId=${ctx.requestId}`,
ctx.trackingCode ? `trackingCode=${ctx.trackingCode}` : null,
ctx.provider ? `provider=${ctx.provider}` : null,
ctx.inquiryType ? `inquiryType=${ctx.inquiryType}` : null,
ctx.durationMs !== undefined ? `durationMs=${ctx.durationMs}` : null,
ctx.success !== undefined ? `success=${ctx.success}` : null,
`msg=${message}`,
].filter(Boolean);
return parts.join(' | ');
}
}

View File

@@ -0,0 +1,66 @@
export interface RetryOptions {
maxAttempts: number;
delayMs: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_SHOULD_RETRY = (error: unknown): boolean => {
if (error && typeof error === 'object') {
// Check for network error codes
if ('code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT' || code === 'ECONNRESET') {
return true;
}
}
// Check for HTTP 500 errors (server errors that might be transient)
if ('response' in error) {
const response = (error as { response?: { status?: number } }).response;
if (response?.status && response.status >= 500 && response.status < 600) {
return true;
}
}
}
return false;
};
/**
* Generic async retry with exponential backoff.
* Used by BaseProvider for transient network failures.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
const {
maxAttempts,
delayMs,
backoffMultiplier = 2,
shouldRetry = DEFAULT_SHOULD_RETRY,
} = options;
let lastError: unknown;
let currentDelay = delayMs;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const isLastAttempt = attempt === maxAttempts;
if (isLastAttempt || !shouldRetry(error)) {
throw error;
}
await sleep(currentDelay);
currentDelay *= backoffMultiplier;
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,25 @@
/**
* Races an async operation against a timeout.
* Throws a distinguishable error for BaseProvider normalization.
*/
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label = 'Operation',
): Promise<T> {
let timeoutHandle: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
(error as NodeJS.ErrnoException).code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeoutHandle!);
}
}

View File

@@ -0,0 +1,11 @@
import { randomBytes } from 'crypto';
import { TRACKING_CODE_PREFIX } from '../constants/app.constants';
/**
* Generates a unique, human-readable tracking code per inquiry.
*/
export function generateTrackingCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const suffix = randomBytes(4).toString('hex').toUpperCase();
return `${TRACKING_CODE_PREFIX}-${date}-${suffix}`;
}

View File

@@ -0,0 +1,43 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { RequestLogger } from '../helpers/request-logger.helper';
/**
* Logs HTTP request duration at the controller boundary.
*/
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly requestLogger = new RequestLogger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<{
method: string;
url: string;
requestId?: string;
}>();
const start = Date.now();
return next.handle().pipe(
tap({
next: () => {
this.requestLogger.logSuccess(
{ requestId: req.requestId ?? 'unknown' },
`${req.method} ${req.url} completed in ${Date.now() - start}ms`,
);
},
error: (err: unknown) => {
this.requestLogger.logFailure(
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
`${req.method} ${req.url} failed`,
err,
);
},
}),
);
}
}

View File

@@ -0,0 +1,31 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { REQUEST_ID_HEADER } from '../constants/app.constants';
/**
* Ensures every request has a unique request ID for tracing.
* Propagates ID via response header and request object.
*/
@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const request = http.getRequest<Record<string, unknown>>();
const response = http.getResponse<{ setHeader: (k: string, v: string) => void }>();
const incoming =
(request.headers as Record<string, string | undefined>)?.[REQUEST_ID_HEADER];
const requestId = incoming ?? uuidv4();
request.requestId = requestId;
response.setHeader(REQUEST_ID_HEADER, requestId);
return next.handle();
}
}

View File

@@ -0,0 +1,21 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
/**
* Ensures inquiry endpoints always emit BaseInquiryResponseDto shape.
*/
@Injectable()
export class ResponseTransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data: BaseInquiryResponseDto) => data),
);
}
}

View File

@@ -0,0 +1,22 @@
import { InquiryType } from '../enums/inquiry-type.enum';
import { ProviderName } from '../enums/provider-name.enum';
/**
* Contract every external provider adapter must fulfill.
* Keeps orchestration logic decoupled from provider implementations (Strategy pattern).
*/
export interface InquiryProvider<TRequest = unknown, TResponse = unknown> {
readonly name: ProviderName;
readonly supportedInquiryTypes: InquiryType[];
isEnabled(): boolean;
execute(
inquiryType: InquiryType,
payload: TRequest,
context: ProviderExecutionContext,
): Promise<TResponse>;
}
export interface ProviderExecutionContext {
requestId: string;
trackingCode: string;
}

View File

@@ -0,0 +1,19 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { REQUEST_ID_HEADER } from '../constants/app.constants';
/**
* Early middleware assignment of request ID (runs before guards).
*/
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const incoming = req.headers[REQUEST_ID_HEADER] as string | undefined;
const requestId = incoming ?? uuidv4();
(req as Request & { requestId: string }).requestId = requestId;
res.setHeader(REQUEST_ID_HEADER, requestId);
next();
}
}

View File

@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import configuration from './configuration';
/**
* Global configuration — single source of truth for env-based settings.
*/
@Global()
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
load: [configuration],
envFilePath: ['.env.local', '.env'],
}),
],
exports: [NestConfigModule],
})
export class AppConfigModule {}

190
src/config/configuration.ts Normal file
View File

@@ -0,0 +1,190 @@
import { InquiryType } from '../common/enums/inquiry-type.enum';
import { ProviderName } from '../common/enums/provider-name.enum';
export type AuthMethod = 'AMITIS' | 'SOAP' | 'OAUTH2' | 'NONE';
export interface InquiryConfig {
url: string;
username: string;
password: string;
authMethod: AuthMethod;
}
export interface ProviderEnvConfig {
enabled: boolean;
timeout: number;
maxRetries: number;
inquiries: Partial<Record<InquiryType, InquiryConfig>>;
}
export interface AmitisAuthServiceConfig {
baseUrl: string;
loginPath: string;
refreshPath: string;
tokenTimeZone: string;
timeout: number;
enabled: boolean;
}
export interface TejaratNouConfig {
enabled: boolean;
timeout: number;
maxRetries: number;
authUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
inquiries: Partial<Record<InquiryType, InquiryConfig>>;
}
export interface InquiryRoutingConfig {
defaultProvider: ProviderName;
fallbackEnabled: boolean;
fallbackProviders: ProviderName[];
}
/**
* Central configuration factory — maps environment variables to typed config.
* Provider routing is deployment-specific and lives under inquiryRouting.
*/
export default () => ({
app: {
port: parseInt(process.env.PORT ?? '8085', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
},
mongodb: {
uri: process.env.MONGODB_URI ?? 'mongodb://localhost:27017/inquiry-gateway',
},
auth: {
apiKey: process.env.API_KEY ?? '',
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET ?? process.env.JWT_SECRET ?? 'change-me-refresh',
jwtAccessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN ?? '15m',
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
jwtEnabled: process.env.JWT_ENABLED !== 'false',
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS ?? '12', 10),
},
throttle: {
ttl: parseInt(process.env.THROTTLE_TTL ?? '60', 10),
limit: parseInt(process.env.THROTTLE_LIMIT ?? '100', 10),
},
amitis: {
baseUrl: process.env.AMITIS_BASE_URL ?? 'https://auth.services.centinsur.ir',
loginPath: process.env.AMITIS_LOGIN_PATH ?? '/api/security/login',
refreshPath: process.env.AMITIS_REFRESH_PATH ?? '/api/security/RefreshToken',
tokenTimeZone: process.env.AMITIS_TOKEN_TIME_ZONE ?? 'Asia/Tehran',
timeout: parseInt(process.env.AMITIS_TIMEOUT ?? '10000', 10),
enabled: process.env.AMITIS_ENABLED !== 'false',
} satisfies AmitisAuthServiceConfig,
hamta: buildProviderConfig('HAMTA'),
moallem: buildProviderConfig('MOALLEM'),
tejaratnou: {
enabled: process.env.TEJARATNOU_ENABLED !== 'false',
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
maxRetries: parseInt(process.env.TEJARATNOU_MAX_RETRIES ?? '2', 10),
authUrl: process.env.TEJARATNOU_AUTH_URL ?? 'https://accounts.tejaratnoins.ir',
clientId: process.env.TEJARATNOU_CLIENT_ID ?? 'api-gateway',
clientSecret: process.env.TEJARATNOU_CLIENT_SECRET ?? '',
username: process.env.TEJARATNOU_USERNAME ?? '',
password: process.env.TEJARATNOU_PASSWORD ?? '',
inquiries: {
[InquiryType.PERSON]: buildInquiryConfig('TEJARATNOU', 'PERSON'),
},
} satisfies TejaratNouConfig,
inquiryRouting: {
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.TEJARATNOU, []),
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
ProviderName.MOALLEM,
]),
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.MOALLEM, [
ProviderName.HAMTA,
]),
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.MOALLEM, [
ProviderName.HAMTA,
]),
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
ProviderName.HAMTA,
]),
[InquiryType.LEGAL_PERSON]: buildInquiryRoutingConfig('LEGAL_PERSON', ProviderName.HAMTA, [
ProviderName.MOALLEM,
]),
[InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []),
} satisfies Record<InquiryType, InquiryRoutingConfig>,
});
function buildProviderConfig(prefix: string): ProviderEnvConfig {
const inquiryTypes = [
InquiryType.PERSON,
InquiryType.REAL_ESTATE,
InquiryType.SHAHKAR,
InquiryType.SHEBA,
InquiryType.POSTAL_CODE,
InquiryType.LEGAL_PERSON,
InquiryType.CAR_PLATE,
];
const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {};
for (const inquiryType of inquiryTypes) {
const inquiryConfig = buildInquiryConfig(prefix, inquiryTypeToEnvName(inquiryType));
if (inquiryConfig.url || inquiryConfig.username) {
inquiries[inquiryType] = inquiryConfig;
}
}
return {
enabled: process.env[`${prefix}_ENABLED`] !== 'false',
timeout: parseInt(process.env[`${prefix}_TIMEOUT`] ?? '10000', 10),
maxRetries: parseInt(process.env[`${prefix}_MAX_RETRIES`] ?? '2', 10),
inquiries,
};
}
function buildInquiryConfig(providerPrefix: string, inquiryPrefix: string): InquiryConfig {
return {
url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '',
username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '',
password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '',
authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod,
};
}
function inquiryTypeToEnvName(inquiryType: InquiryType): string {
const mapping: Record<InquiryType, string> = {
[InquiryType.PERSON]: 'PERSON',
[InquiryType.REAL_ESTATE]: 'REAL_ESTATE',
[InquiryType.SHAHKAR]: 'SHAHKAR',
[InquiryType.SHEBA]: 'SHEBA',
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
[InquiryType.CAR_PLATE]: 'CAR_PLATE',
};
return mapping[inquiryType];
}
function buildInquiryRoutingConfig(
prefix: string,
defaultProvider: ProviderName,
fallbackProviders: ProviderName[],
): InquiryRoutingConfig {
return {
defaultProvider: (process.env[`${prefix}_DEFAULT_PROVIDER`] ?? defaultProvider) as ProviderName,
fallbackEnabled: parseBoolean(process.env[`${prefix}_FALLBACK_ENABLED`], false),
fallbackProviders: parseProviderList(
process.env[`${prefix}_FALLBACK_PROVIDERS`] ?? fallbackProviders.join(','),
),
};
}
function parseProviderList(value: string): ProviderName[] {
if (!value.trim()) return [];
return value.split(',').map((p) => p.trim() as ProviderName);
}
function parseBoolean(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined) return defaultValue;
return value.toLowerCase() === 'true';
}
// Made with Bob

View File

@@ -0,0 +1,98 @@
import "@opentelemetry/api-logs";
import loggerProvider from "./logger";
// Get a logger instance
const logger = loggerProvider.getLogger("default", "1.0.0");
// Store original console methods
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
};
// Map severity levels
const SeverityNumber = {
DEBUG: 5,
INFO: 9,
WARN: 13,
ERROR: 17,
};
// Override console methods
console.log = function (...args: any[]) {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: message,
attributes: {},
});
originalConsole.log.apply(console, args);
};
console.info = function (...args: any[]) {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
logger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: message,
attributes: {},
});
originalConsole.info.apply(console, args);
};
console.warn = function (...args: any[]) {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
logger.emit({
severityNumber: SeverityNumber.WARN,
severityText: "WARN",
body: message,
attributes: {},
});
originalConsole.warn.apply(console, args);
};
console.error = function (...args: any[]) {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
logger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: message,
attributes: {},
});
originalConsole.error.apply(console, args);
};
console.debug = function (...args: any[]) {
const message = args
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg)))
.join(" ");
logger.emit({
severityNumber: SeverityNumber.DEBUG,
severityText: "DEBUG",
body: message,
attributes: {},
});
originalConsole.debug.apply(console, args);
};

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from '../users/schemas/user.schema';
import { DatabaseSeederService } from './database-seeder.service';
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
providers: [DatabaseSeederService],
})
export class DatabaseSeederModule {}

View File

@@ -0,0 +1,158 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { Model, Types } from 'mongoose';
import { User, UserDocument } from '../users/schemas/user.schema';
type ExtendedJsonValue =
| string
| number
| boolean
| null
| ExtendedJsonValue[]
| { [key: string]: ExtendedJsonValue };
interface SeedUser {
_id?: Types.ObjectId;
fullName: string;
username: string;
email: string;
mobile?: string;
password: string;
role: string;
clientType: string;
appName: string;
clientName: string;
isActive: boolean;
isBlocked: boolean;
apiKey?: string;
allowedInquiries: string[];
requestLimitPerMinute: number;
requestLimitPerDay: number;
totalRequests?: number;
passwordChangedAt?: Date;
createdBy?: Types.ObjectId;
createdAt?: Date;
updatedAt?: Date;
lastLoginAt?: Date;
refreshToken?: string;
}
@Injectable()
export class DatabaseSeederService implements OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseSeederService.name);
private readonly usersSeedFile = 'inquiry-gateway.users.json';
constructor(@InjectModel(User.name) private readonly userModel: Model<UserDocument>) {}
async onApplicationBootstrap(): Promise<void> {
await this.seedUsers();
}
private async seedUsers(): Promise<void> {
const users = this.loadSeedUsers();
let inserted = 0;
let skipped = 0;
for (const user of users) {
const existing = await this.userModel.exists({
$or: [{ _id: user._id }, { username: user.username }, { email: user.email }],
});
if (existing) {
skipped += 1;
continue;
}
await this.userModel.create(user);
inserted += 1;
}
if (inserted > 0) {
this.logger.log(`Seeded ${inserted} baseline user(s); skipped ${skipped} existing user(s).`);
return;
}
this.logger.log(`Baseline users already exist; skipped ${skipped} user(s).`);
}
private loadSeedUsers(): SeedUser[] {
const seedPath = this.resolveSeedPath();
const raw = readFileSync(seedPath, 'utf8');
const parsed = JSON.parse(raw) as ExtendedJsonValue;
const normalized = this.normalizeExtendedJson(parsed);
if (!Array.isArray(normalized)) {
throw new Error(`${this.usersSeedFile} must contain an array of users`);
}
return normalized.map((user) => {
if (!this.isSeedUser(user)) {
throw new Error(`${this.usersSeedFile} contains an invalid user document`);
}
return {
...user,
username: user.username.toLowerCase().trim(),
email: user.email.toLowerCase().trim(),
};
});
}
private resolveSeedPath(): string {
const candidates = [
join(__dirname, '..', 'public', this.usersSeedFile),
join(process.cwd(), 'src', 'public', this.usersSeedFile),
];
const seedPath = candidates.find((candidate) => existsSync(candidate));
if (!seedPath) {
throw new Error(`Unable to find seed file: ${this.usersSeedFile}`);
}
return seedPath;
}
private normalizeExtendedJson(value: ExtendedJsonValue): unknown {
if (Array.isArray(value)) {
return value.map((item) => this.normalizeExtendedJson(item));
}
if (value === null || typeof value !== 'object') {
return value;
}
if (typeof value.$oid === 'string') {
return new Types.ObjectId(value.$oid);
}
if (typeof value.$date === 'string') {
return new Date(value.$date);
}
return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, child]) => {
acc[key] = this.normalizeExtendedJson(child);
return acc;
}, {});
}
private isSeedUser(value: unknown): value is SeedUser {
if (!value || typeof value !== 'object') {
return false;
}
const user = value as Partial<SeedUser>;
return (
typeof user.fullName === 'string' &&
typeof user.username === 'string' &&
typeof user.email === 'string' &&
typeof user.password === 'string' &&
typeof user.role === 'string' &&
typeof user.clientType === 'string' &&
Array.isArray(user.allowedInquiries) &&
typeof user.requestLimitPerMinute === 'number' &&
typeof user.requestLimitPerDay === 'number'
);
}
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class CivilRegistrationRequestDto {
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '1370-01-01', description: 'Birth date as YYYY-MM-DD or YYYYMMDD' })
@IsString()
@IsNotEmpty()
@Matches(/^\d{4}-?\d{2}-?\d{2}$/, {
message: 'birthDate must be in YYYY-MM-DD or YYYYMMDD format',
})
birthDate!: string;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
dateHasPostfix?: number;
}

View File

@@ -0,0 +1,45 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
export class CivilRegistrationResponseDto {
@ApiPropertyOptional()
NIN?: string;
@ApiPropertyOptional()
BirthDate?: string;
@ApiPropertyOptional()
FirstName?: string;
@ApiPropertyOptional()
LastName?: string;
@ApiPropertyOptional()
FatherName?: string;
@ApiPropertyOptional()
SSN?: string;
@ApiPropertyOptional()
Gender?: string;
@ApiPropertyOptional()
DeathStatus?: string;
@ApiPropertyOptional()
DeathDate?: string;
@ApiPropertyOptional()
PostalCode?: string;
@ApiPropertyOptional()
PostalCodeDescription?: string;
@ApiPropertyOptional()
ExceptionMessage?: string;
@ApiPropertyOptional()
Message?: string;
@ApiPropertyOptional()
Error?: string;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
export class GenericInquiryResponseDto {
@ApiProperty()
success!: boolean;
@ApiProperty()
provider!: string;
@ApiProperty()
trackingCode!: string;
@ApiPropertyOptional()
message?: string;
@ApiPropertyOptional({ type: Object })
data?: Record<string, unknown>;
@ApiPropertyOptional({ type: NormalizedErrorDto })
error?: NormalizedErrorDto;
@ApiProperty()
duration!: number;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PersonInquiryDataDto {
@ApiProperty({ example: '0012345678' })
nationalCode!: string;
@ApiProperty({ example: '1370-05-15' })
birthDate!: string;
@ApiPropertyOptional({ example: 'علی محمدی' })
fullName?: string;
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class PersonInquiryRequestDto {
@ApiProperty({ example: '0012345678', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '1378-11-24', description: 'Jalali birth date (YYYY-MM-DD)' })
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthDate must be in YYYY-MM-DD format' })
@IsNotEmpty()
birthDate!: string;
@ApiProperty({ example: 0, required: false, description: 'Civil registration date postfix flag' })
@IsOptional()
@IsInt()
dateHasPostfix?: number;
}

View File

@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import { PersonInquiryDataDto } from './person-inquiry-data.dto';
/** Swagger concrete type for person inquiry responses */
export class PersonInquiryResponseDto {
@ApiProperty()
success!: boolean;
@ApiProperty()
provider!: string;
@ApiProperty()
trackingCode!: string;
@ApiPropertyOptional()
message?: string;
@ApiPropertyOptional({ type: PersonInquiryDataDto })
data?: PersonInquiryDataDto;
@ApiPropertyOptional({ type: NormalizedErrorDto })
error?: NormalizedErrorDto;
@ApiProperty()
duration!: number;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class PostalCodeRequestDto {
@ApiProperty({ example: '1234567890', description: 'Postal code' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'postalCode must be exactly 10 digits' })
postalCode!: string;
}

View File

@@ -0,0 +1,57 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PostalCodeResponseDto {
@ApiPropertyOptional()
Province?: string;
@ApiPropertyOptional()
TownShip?: string;
@ApiPropertyOptional()
Zone?: string;
@ApiPropertyOptional()
Village?: string;
@ApiPropertyOptional()
LocalityType?: string;
@ApiPropertyOptional()
LocalityName?: string;
@ApiPropertyOptional()
LocalityCode?: string;
@ApiPropertyOptional()
SubLocality?: string;
@ApiPropertyOptional()
Street?: string;
@ApiPropertyOptional()
Street2?: string;
@ApiPropertyOptional()
HouseNumber?: string;
@ApiPropertyOptional()
Floor?: string;
@ApiPropertyOptional()
SideFloor?: string;
@ApiPropertyOptional()
BuildingName?: string;
@ApiPropertyOptional()
Description?: string;
@ApiPropertyOptional()
ErrorCode?: string;
@ApiPropertyOptional()
ErrorMessage?: string;
@ApiPropertyOptional()
TraceID?: string;
}

View File

@@ -0,0 +1,27 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SayahRequestDto {
@ApiProperty({ example: '0', description: '0 for natural person' })
@IsString()
@IsIn(['0'])
accountOwnerType!: string;
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalId must be exactly 10 digits' })
nationalId!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
legalId?: string | null;
@ApiProperty({ example: 'IR000000000000000000000000', description: 'Iranian Sheba ID' })
@IsString()
@Length(26, 26)
@Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' })
shebaId!: string;
}

View File

@@ -0,0 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SayahResponseDto {
@ApiPropertyOptional()
ReturnValue?: boolean;
@ApiPropertyOptional()
HasError?: boolean;
@ApiPropertyOptional({ type: Object, nullable: true })
Errors?: Record<string, string> | null;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class ShahkarInquiryRequestDto {
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '09226187419', description: 'Iranian mobile number' })
@IsString()
@IsNotEmpty()
@Matches(/^09\d{9}$/, { message: 'mobileNo must be an Iranian mobile number' })
mobileNo!: string;
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class ShahkarRequestDto {
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '09226187419', description: 'Iranian mobile number' })
@IsString()
@IsNotEmpty()
@Matches(/^09\d{9}$/, { message: 'mobileNo must be an Iranian mobile number' })
mobileNo!: string;
}

View File

@@ -0,0 +1,21 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
export class ShahkarResponseDto {
@ApiPropertyOptional()
response?: number;
@ApiPropertyOptional()
requestId?: string;
@ApiPropertyOptional()
id?: string;
@ApiPropertyOptional()
result?: string;
@ApiPropertyOptional()
comment?: string;
@ApiPropertyOptional()
ErrorNams?: string;
}

View File

@@ -0,0 +1,140 @@
import {
Body,
Controller,
Post,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InquiryAccessGuard } from '../auth/guards/inquiry-access.guard';
import { InquiryAccess } from '../auth/decorators/inquiry-access.decorator';
import { InquiryType } from '../common/enums/inquiry-type.enum';
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor';
import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard';
import { PersonInquiryRequestDto } from './dto/person-inquiry-request.dto';
import { PersonInquiryDataDto } from './dto/person-inquiry-data.dto';
import { PersonInquiryResponseDto } from './dto/person-inquiry-response.dto';
import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto';
import { PostalCodeRequestDto } from './dto/postal-code-request.dto';
import { ShahkarRequestDto } from './dto/shahkar-request.dto';
import { SayahRequestDto } from './dto/sayah-request.dto';
import { InquiryService } from './inquiry.service';
/**
* Unified inquiry endpoints — protected by JWT, inquiry RBAC, and rate limits.
*/
@ApiTags('Inquiries')
@ApiBearerAuth()
@Controller('inquiry')
@UseGuards(JwtAuthGuard, InquiryAccessGuard, UserRateLimitGuard)
@UseInterceptors(ResponseTransformInterceptor)
export class InquiryController {
constructor(private readonly inquiryService: InquiryService) {}
@Post('person')
@InquiryAccess(InquiryType.PERSON)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Person identity inquiry' })
@ApiResponse({ status: 200, type: PersonInquiryResponseDto })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Inquiry access denied' })
@ApiResponse({ status: 429, description: 'Too many requests' })
async inquirePerson(
@Body() dto: PersonInquiryRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
const requestId = req.requestId ?? 'unknown';
const result = await this.inquiryService.inquirePerson(dto, requestId);
req.trackingCode = result.trackingCode;
return result;
}
@Post('realEstate')
@InquiryAccess(InquiryType.REAL_ESTATE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Real estate inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireRealEstate(
@Body() payload: Record<string, unknown>,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(InquiryType.REAL_ESTATE, payload, req);
}
@Post('sheba')
@InquiryAccess(InquiryType.SHEBA)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Sheba inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireSheba(
@Body() payload: SayahRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(InquiryType.SHEBA, payload as unknown as Record<string, unknown>, req);
}
@Post('shahkar')
@InquiryAccess(InquiryType.SHAHKAR)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Shahkar inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireShahkar(
@Body() payload: ShahkarRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.SHAHKAR,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('postalCode')
@InquiryAccess(InquiryType.POSTAL_CODE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Postal code inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePostalCode(
@Body() payload: PostalCodeRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POSTAL_CODE,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('legalPerson')
@InquiryAccess(InquiryType.LEGAL_PERSON)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Legal person inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireLegalPerson(
@Body() payload: Record<string, unknown>,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req);
}
private async inquireGeneric(
inquiryType: InquiryType,
payload: Record<string, unknown>,
req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
const requestId = req.requestId ?? 'unknown';
const result = await this.inquiryService.inquire(inquiryType, payload, requestId);
req.trackingCode = result.trackingCode;
return result;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { LoggingModule } from '../logging/logging.module';
import { ProvidersModule } from '../providers/providers.module';
import { RateLimitModule } from '../rate-limit/rate-limit.module';
import { InquiryController } from './inquiry.controller';
import { InquiryService } from './inquiry.service';
@Module({
imports: [ProvidersModule, LoggingModule, AuthModule, RateLimitModule],
controllers: [InquiryController],
providers: [InquiryService],
})
export class InquiryModule {}

View File

@@ -0,0 +1,245 @@
import { Injectable } from '@nestjs/common';
import { InquiryType } from '../common/enums/inquiry-type.enum';
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
import { ProviderName } from '../common/enums/provider-name.enum';
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { InquiryException } from '../common/exceptions/inquiry.exception';
import { InquiryLogService } from '../logging/inquiry-log.service';
import {
LegacyInquiryPayload,
PersonInquiryPayload,
PersonInquiryResult,
} from '../providers/shared/legacy-api.provider.abstract';
import { ProviderOrchestratorService } from '../providers/strategy/provider-orchestrator.service';
import { PersonInquiryRequestDto } from './dto/person-inquiry-request.dto';
import { PersonInquiryDataDto } from './dto/person-inquiry-data.dto';
@Injectable()
export class InquiryService {
constructor(
private readonly orchestrator: ProviderOrchestratorService,
private readonly inquiryLogService: InquiryLogService,
) {}
/**
* Person inquiry — unified business flow with provider fallback and audit logging.
*/
async inquirePerson(
dto: PersonInquiryRequestDto,
requestId: string,
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
const trackingCode = generateTrackingCode();
const payload: PersonInquiryPayload = {
nationalCode: dto.nationalCode,
birthDate: dto.birthDate,
dateHasPostfix: (dto as PersonInquiryRequestDto & { dateHasPostfix?: number }).dateHasPostfix,
};
const start = Date.now();
try {
const result = await this.executeInquiry<PersonInquiryPayload, PersonInquiryResult>(
InquiryType.PERSON,
payload,
requestId,
trackingCode,
);
const response = this.buildSuccessResponse(
result.data,
result.provider,
trackingCode,
result.duration,
'Person inquiry completed successfully',
);
await this.persistLog({
inquiryType: InquiryType.PERSON,
provider: result.provider,
requestPayload: payload as unknown as Record<string, unknown>,
responsePayload: response.data as unknown as Record<string, unknown>,
status: InquiryStatus.SUCCESS,
duration: result.duration,
requestId,
trackingCode,
});
return response;
} catch (error) {
const duration = Date.now() - start;
const normalized = this.extractError(error);
const response: BaseInquiryResponseDto<PersonInquiryDataDto> = {
success: false,
provider: 'GATEWAY',
trackingCode,
message: normalized.message,
error: normalized,
duration,
};
await this.persistLog({
inquiryType: InquiryType.PERSON,
provider: ProviderName.HAMTA,
requestPayload: payload as unknown as Record<string, unknown>,
status: InquiryStatus.FAILURE,
duration,
requestId,
trackingCode,
error: normalized as unknown as Record<string, unknown>,
}).catch(() => undefined);
return response;
}
}
async inquire(
inquiryType: InquiryType,
payload: LegacyInquiryPayload,
requestId: string,
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
const trackingCode = generateTrackingCode();
const start = Date.now();
try {
const result = await this.executeInquiry<LegacyInquiryPayload, unknown>(
inquiryType,
payload,
requestId,
trackingCode,
);
const response = this.buildGenericSuccessResponse(
result.data,
result.provider,
trackingCode,
result.duration,
`${this.getInquiryLabel(inquiryType)} completed successfully`,
);
await this.persistLog({
inquiryType,
provider: result.provider,
requestPayload: payload,
responsePayload: response.data,
status: InquiryStatus.SUCCESS,
duration: result.duration,
requestId,
trackingCode,
});
return response;
} catch (error) {
const duration = Date.now() - start;
const normalized = this.extractError(error);
const response: BaseInquiryResponseDto<Record<string, unknown>> = {
success: false,
provider: 'GATEWAY',
trackingCode,
message: normalized.message,
error: normalized,
duration,
};
await this.persistLog({
inquiryType,
provider: ProviderName.HAMTA,
requestPayload: payload,
status: InquiryStatus.FAILURE,
duration,
requestId,
trackingCode,
error: normalized as unknown as Record<string, unknown>,
}).catch(() => undefined);
return response;
}
}
private async executeInquiry<TRequest, TResponse>(
inquiryType: InquiryType,
payload: TRequest,
requestId: string,
trackingCode: string,
) {
return this.orchestrator.executeWithFallback<TRequest, TResponse>(inquiryType, payload, {
requestId,
trackingCode,
});
}
private buildSuccessResponse(
result: PersonInquiryResult,
provider: string,
trackingCode: string,
duration: number,
message: string,
): BaseInquiryResponseDto<PersonInquiryDataDto> {
return {
success: true,
provider,
trackingCode,
message,
duration,
data: {
nationalCode: result.nationalCode,
birthDate: result.birthDate,
fullName: result.fullName,
},
};
}
private buildGenericSuccessResponse(
result: unknown,
provider: string,
trackingCode: string,
duration: number,
message: string,
): BaseInquiryResponseDto<Record<string, unknown>> {
return {
success: true,
provider,
trackingCode,
message,
duration,
data: this.toResponseData(result),
};
}
private toResponseData(result: unknown): Record<string, unknown> {
if (result && typeof result === 'object' && 'raw' in result) {
const raw = (result as { raw: unknown }).raw;
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : { raw };
}
return result && typeof result === 'object'
? (result as Record<string, unknown>)
: { raw: result };
}
private getInquiryLabel(inquiryType: InquiryType): string {
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
}
private extractError(error: unknown): NormalizedErrorDto {
if (error instanceof InquiryException) {
return error.normalizedError;
}
if (error && typeof error === 'object' && 'normalizedError' in error) {
return (error as { normalizedError: NormalizedErrorDto }).normalizedError;
}
return {
code: 'INQUIRY_FAILED',
message: error instanceof Error ? error.message : 'Inquiry failed',
};
}
private async persistLog(
input: Parameters<InquiryLogService['create']>[0],
): Promise<void> {
await this.inquiryLogService.create(input);
}
}

24
src/logger.ts Normal file
View File

@@ -0,0 +1,24 @@
import {
LoggerProvider,
BatchLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
// Create a resource with your service information
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "nodejs-console-app",
});
// Configure the OTLP exporter
// It automatically reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
const logExporter = new OTLPLogExporter({});
// Create and configure the logger provider
const loggerProvider = new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor(logExporter)], // Configure batch processor
});
export default loggerProvider;

View File

@@ -0,0 +1,79 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsISO8601, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { InquiryStatus } from '../../common/enums/inquiry-status.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
export enum LogTimeRangePreset {
LAST_24_HOURS = 'LAST_24_HOURS',
LAST_7_DAYS = 'LAST_7_DAYS',
LAST_30_DAYS = 'LAST_30_DAYS',
}
export enum LogTimelineInterval {
HOUR = 'HOUR',
DAY = 'DAY',
MONTH = 'MONTH',
}
export class InquiryLogQueryDto {
@ApiPropertyOptional({ enum: InquiryType })
@IsOptional()
@IsEnum(InquiryType)
inquiryType?: InquiryType;
@ApiPropertyOptional({ enum: ProviderName })
@IsOptional()
@IsEnum(ProviderName)
provider?: ProviderName;
@ApiPropertyOptional({ enum: InquiryStatus })
@IsOptional()
@IsEnum(InquiryStatus)
status?: InquiryStatus;
@ApiPropertyOptional({ enum: LogTimeRangePreset })
@IsOptional()
@IsEnum(LogTimeRangePreset)
preset?: LogTimeRangePreset;
@ApiPropertyOptional({ example: '2026-06-01T00:00:00.000Z' })
@IsOptional()
@IsISO8601()
from?: string;
@ApiPropertyOptional({ example: '2026-06-08T00:00:00.000Z' })
@IsOptional()
@IsISO8601()
to?: string;
@ApiPropertyOptional({ description: 'Matches error.code' })
@IsOptional()
@IsString()
errorCode?: string;
@ApiPropertyOptional({ description: 'Case-insensitive match against error.message' })
@IsOptional()
@IsString()
errorMessage?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 500, default: 50 })
@IsOptional()
@IsInt()
@Min(1)
@Max(500)
limit?: number;
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@IsInt()
@Min(1)
page?: number;
}
export class InquiryLogTimelineQueryDto extends InquiryLogQueryDto {
@ApiPropertyOptional({ enum: LogTimelineInterval, default: LogTimelineInterval.HOUR })
@IsOptional()
@IsEnum(LogTimelineInterval)
interval?: LogTimelineInterval;
}

View File

@@ -0,0 +1,414 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { FilterQuery, Model } from 'mongoose';
import { InquiryStatus } from '../common/enums/inquiry-status.enum';
import { InquiryType } from '../common/enums/inquiry-type.enum';
import { ProviderName } from '../common/enums/provider-name.enum';
import { maskPayload } from '../common/helpers/mask-payload.helper';
import {
InquiryLogQueryDto,
InquiryLogTimelineQueryDto,
LogTimelineInterval,
LogTimeRangePreset,
} from './dto/inquiry-log-query.dto';
import { InquiryLog, InquiryLogDocument } from './schemas/inquiry-log.schema';
export interface CreateInquiryLogInput {
inquiryType: InquiryType;
provider: ProviderName;
requestPayload: Record<string, unknown>;
responsePayload?: Record<string, unknown>;
status: InquiryStatus;
duration: number;
requestId: string;
trackingCode: string;
error?: Record<string, unknown>;
}
export interface InquiryLogListItem {
id: string;
inquiryType: InquiryType;
provider: ProviderName;
status: InquiryStatus;
duration: number;
requestId: string;
trackingCode: string;
maskedRequest: Record<string, unknown>;
responsePayload?: Record<string, unknown>;
error?: Record<string, unknown>;
createdAt?: Date;
}
export interface InquiryLogSummary {
total: number;
success: number;
failure: number;
partial: number;
successRate: number;
failureRate: number;
averageDuration: number;
minDuration: number;
maxDuration: number;
}
export interface CommonErrorItem {
code: string;
message: string;
count: number;
lastSeenAt: Date;
}
export interface DurationExtremeItem {
id: string;
inquiryType: InquiryType;
provider: ProviderName;
status: InquiryStatus;
duration: number;
requestId: string;
trackingCode: string;
error?: Record<string, unknown>;
createdAt?: Date;
}
export interface TimelineBucket {
bucket: string;
total: number;
success: number;
failure: number;
partial: number;
averageDuration: number;
maxDuration: number;
commonErrorCode?: string;
}
@Injectable()
export class InquiryLogService {
constructor(
@InjectModel(InquiryLog.name)
private readonly inquiryLogModel: Model<InquiryLogDocument>,
) {}
async create(input: CreateInquiryLogInput): Promise<InquiryLogDocument> {
return this.inquiryLogModel.create({
...input,
maskedRequest: maskPayload(input.requestPayload),
});
}
async findLogs(query: InquiryLogQueryDto): Promise<{
data: InquiryLogListItem[];
page: number;
limit: number;
total: number;
}> {
const filter = this.buildFilter(query);
const limit = query.limit ?? 50;
const page = query.page ?? 1;
const skip = (page - 1) * limit;
const [logs, total] = await Promise.all([
this.inquiryLogModel
.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.select('inquiryType provider status duration requestId trackingCode maskedRequest responsePayload error createdAt')
.lean()
.exec(),
this.inquiryLogModel.countDocuments(filter),
]);
return {
data: logs.map((log) => ({
id: log._id.toString(),
inquiryType: log.inquiryType,
provider: log.provider,
status: log.status,
duration: log.duration,
requestId: log.requestId,
trackingCode: log.trackingCode,
maskedRequest: log.maskedRequest,
responsePayload: log.responsePayload,
error: log.error,
createdAt: log.createdAt,
})),
page,
limit,
total,
};
}
async getSummary(query: InquiryLogQueryDto): Promise<InquiryLogSummary> {
const [summary] = await this.inquiryLogModel.aggregate<InquiryLogSummary>([
{ $match: this.buildFilter(query) },
{
$group: {
_id: null,
total: { $sum: 1 },
success: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.SUCCESS] }, 1, 0] },
},
failure: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.FAILURE] }, 1, 0] },
},
partial: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.PARTIAL] }, 1, 0] },
},
averageDuration: { $avg: '$duration' },
minDuration: { $min: '$duration' },
maxDuration: { $max: '$duration' },
},
},
{
$project: {
_id: 0,
total: 1,
success: 1,
failure: 1,
partial: 1,
successRate: {
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$success', '$total'] }],
},
failureRate: {
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$failure', '$total'] }],
},
averageDuration: { $ifNull: ['$averageDuration', 0] },
minDuration: { $ifNull: ['$minDuration', 0] },
maxDuration: { $ifNull: ['$maxDuration', 0] },
},
},
]);
return summary ?? this.emptySummary();
}
async getCommonErrors(query: InquiryLogQueryDto): Promise<CommonErrorItem[]> {
const limit = query.limit ?? 10;
return this.inquiryLogModel.aggregate<CommonErrorItem>([
{
$match: {
...this.buildFilter(query),
error: { $exists: true, $ne: null },
},
},
{
$group: {
_id: {
code: { $ifNull: ['$error.code', 'UNKNOWN_ERROR'] },
message: { $ifNull: ['$error.message', 'Unknown error'] },
},
count: { $sum: 1 },
lastSeenAt: { $max: '$createdAt' },
},
},
{ $sort: { count: -1, lastSeenAt: -1 } },
{ $limit: limit },
{
$project: {
_id: 0,
code: '$_id.code',
message: '$_id.message',
count: 1,
lastSeenAt: 1,
},
},
]);
}
async getDurationExtremes(query: InquiryLogQueryDto): Promise<{
fastest: DurationExtremeItem[];
slowest: DurationExtremeItem[];
}> {
const filter = this.buildFilter(query);
const limit = query.limit ?? 10;
const projection =
'inquiryType provider status duration requestId trackingCode error createdAt';
const [fastest, slowest] = await Promise.all([
this.inquiryLogModel.find(filter).sort({ duration: 1 }).limit(limit).select(projection).lean(),
this.inquiryLogModel.find(filter).sort({ duration: -1 }).limit(limit).select(projection).lean(),
]);
return {
fastest: fastest.map((log) => this.toDurationExtremeItem(log)),
slowest: slowest.map((log) => this.toDurationExtremeItem(log)),
};
}
async getTimeline(query: InquiryLogTimelineQueryDto): Promise<TimelineBucket[]> {
const interval = query.interval ?? LogTimelineInterval.HOUR;
return this.inquiryLogModel.aggregate<TimelineBucket>([
{ $match: this.buildFilter(query) },
{
$group: {
_id: {
bucket: {
$dateToString: {
format: this.timelineFormat(interval),
date: '$createdAt',
timezone: 'Asia/Tehran',
},
},
errorCode: { $ifNull: ['$error.code', null] },
},
total: { $sum: 1 },
success: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.SUCCESS] }, 1, 0] },
},
failure: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.FAILURE] }, 1, 0] },
},
partial: {
$sum: { $cond: [{ $eq: ['$status', InquiryStatus.PARTIAL] }, 1, 0] },
},
durationSum: { $sum: '$duration' },
maxDuration: { $max: '$duration' },
},
},
{
$addFields: {
errorCount: {
$cond: [{ $ne: ['$_id.errorCode', null] }, '$total', 0],
},
},
},
{ $sort: { '_id.bucket': 1, errorCount: -1 } },
{
$group: {
_id: '$_id.bucket',
total: { $sum: '$total' },
success: { $sum: '$success' },
failure: { $sum: '$failure' },
partial: { $sum: '$partial' },
durationSum: { $sum: '$durationSum' },
maxDuration: { $max: '$maxDuration' },
commonErrorCode: { $first: '$_id.errorCode' },
},
},
{ $sort: { _id: 1 } },
{
$project: {
_id: 0,
bucket: '$_id',
total: 1,
success: 1,
failure: 1,
partial: 1,
averageDuration: {
$cond: [{ $eq: ['$total', 0] }, 0, { $divide: ['$durationSum', '$total'] }],
},
maxDuration: { $ifNull: ['$maxDuration', 0] },
commonErrorCode: 1,
},
},
]);
}
private buildFilter(query: InquiryLogQueryDto): FilterQuery<InquiryLogDocument> {
const filter: FilterQuery<InquiryLogDocument> = {};
const createdAt = this.buildCreatedAtFilter(query);
if (createdAt) {
filter.createdAt = createdAt;
}
if (query.inquiryType) {
filter.inquiryType = query.inquiryType;
}
if (query.provider) {
filter.provider = query.provider;
}
if (query.status) {
filter.status = query.status;
}
if (query.errorCode) {
filter['error.code'] = query.errorCode;
}
if (query.errorMessage) {
filter['error.message'] = { $regex: query.errorMessage, $options: 'i' };
}
return filter;
}
private buildCreatedAtFilter(query: InquiryLogQueryDto): { $gte?: Date; $lte?: Date } | undefined {
if (!query.from && !query.to && !query.preset) {
return undefined;
}
const to = query.to ? new Date(query.to) : undefined;
const from = query.from ? new Date(query.from) : this.defaultFromDate(query.preset);
return {
...(from && { $gte: from }),
...(to && { $lte: to }),
};
}
private defaultFromDate(preset?: LogTimeRangePreset): Date {
const now = new Date();
const selectedPreset = preset ?? LogTimeRangePreset.LAST_24_HOURS;
switch (selectedPreset) {
case LogTimeRangePreset.LAST_30_DAYS:
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case LogTimeRangePreset.LAST_7_DAYS:
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case LogTimeRangePreset.LAST_24_HOURS:
default:
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
}
}
private timelineFormat(interval: LogTimelineInterval): string {
switch (interval) {
case LogTimelineInterval.MONTH:
return '%Y-%m';
case LogTimelineInterval.DAY:
return '%Y-%m-%d';
case LogTimelineInterval.HOUR:
default:
return '%Y-%m-%d %H:00';
}
}
private emptySummary(): InquiryLogSummary {
return {
total: 0,
success: 0,
failure: 0,
partial: 0,
successRate: 0,
failureRate: 0,
averageDuration: 0,
minDuration: 0,
maxDuration: 0,
};
}
private toDurationExtremeItem(log: {
_id: unknown;
inquiryType: InquiryType;
provider: ProviderName;
status: InquiryStatus;
duration: number;
requestId: string;
trackingCode: string;
error?: Record<string, unknown>;
createdAt?: Date;
}): DurationExtremeItem {
return {
id: String(log._id),
inquiryType: log.inquiryType,
provider: log.provider,
status: log.status,
duration: log.duration,
requestId: log.requestId,
trackingCode: log.trackingCode,
error: log.error,
createdAt: log.createdAt,
};
}
}

View File

@@ -0,0 +1,47 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ADMIN_ROLES } from '../common/enums/role.enum';
import { InquiryLogQueryDto, InquiryLogTimelineQueryDto } from './dto/inquiry-log-query.dto';
import { InquiryLogService } from './inquiry-log.service';
@ApiTags('Inquiry Logs')
@ApiBearerAuth()
@Controller('inquiry-logs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(...ADMIN_ROLES)
export class InquiryLogsController {
constructor(private readonly inquiryLogService: InquiryLogService) {}
@Get()
@ApiOperation({ summary: 'Search inquiry request logs' })
async findLogs(@Query() query: InquiryLogQueryDto) {
return this.inquiryLogService.findLogs(query);
}
@Get('summary')
@ApiOperation({ summary: 'Get request totals, success/failure counts, and duration metrics' })
async getSummary(@Query() query: InquiryLogQueryDto) {
return this.inquiryLogService.getSummary(query);
}
@Get('errors/common')
@ApiOperation({ summary: 'Get most common errors for a provider, inquiry type, and time range' })
async getCommonErrors(@Query() query: InquiryLogQueryDto) {
return this.inquiryLogService.getCommonErrors(query);
}
@Get('durations/extremes')
@ApiOperation({ summary: 'Get fastest and slowest inquiry requests' })
async getDurationExtremes(@Query() query: InquiryLogQueryDto) {
return this.inquiryLogService.getDurationExtremes(query);
}
@Get('timeline')
@ApiOperation({ summary: 'Get request volume, status, error, and duration trend buckets' })
async getTimeline(@Query() query: InquiryLogTimelineQueryDto) {
return this.inquiryLogService.getTimeline(query);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { InquiryLogsController } from './inquiry-logs.controller';
import { InquiryLogService } from './inquiry-log.service';
import { InquiryLog, InquiryLogSchema } from './schemas/inquiry-log.schema';
@Module({
imports: [
MongooseModule.forFeature([{ name: InquiryLog.name, schema: InquiryLogSchema }]),
],
controllers: [InquiryLogsController],
providers: [InquiryLogService],
exports: [InquiryLogService],
})
export class LoggingModule {}

View File

@@ -0,0 +1,51 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
import { InquiryStatus } from '../../common/enums/inquiry-status.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
export type InquiryLogDocument = HydratedDocument<InquiryLog>;
/**
* MongoDB audit trail for every inquiry attempt.
*/
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'inquiry_logs' })
export class InquiryLog {
@Prop({ required: true, enum: InquiryType })
inquiryType!: InquiryType;
@Prop({ required: true, enum: ProviderName })
provider!: ProviderName;
@Prop({ type: Object, required: true })
requestPayload!: Record<string, unknown>;
@Prop({ type: Object, required: true })
maskedRequest!: Record<string, unknown>;
@Prop({ type: Object })
responsePayload?: Record<string, unknown>;
@Prop({ required: true, enum: InquiryStatus })
status!: InquiryStatus;
@Prop({ required: true })
duration!: number;
@Prop({ required: true, index: true })
requestId!: string;
@Prop({ required: true, index: true })
trackingCode!: string;
@Prop({ type: Object })
error?: Record<string, unknown>;
createdAt?: Date;
}
export const InquiryLogSchema = SchemaFactory.createForClass(InquiryLog);
InquiryLogSchema.index({ createdAt: -1, provider: 1, inquiryType: 1, status: 1 });
InquiryLogSchema.index({ provider: 1, inquiryType: 1, createdAt: -1 });
InquiryLogSchema.index({ 'error.code': 1, createdAt: -1 });

88
src/main.ts Normal file
View File

@@ -0,0 +1,88 @@
import './telemetry';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module';
import { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger';
import {
recordHttpRequestEnd,
recordHttpRequestStart,
shutdownTelemetry,
} from './telemetry';
async function bootstrap(): Promise<void> {
const logger = new OpenTelemetryNestLogger();
Logger.overrideLogger(logger);
const app = await NestFactory.create(AppModule, { logger });
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port', 8085);
app.use((req: Request, res: Response, next: NextFunction) => {
const start = Date.now();
const attributes = {
'http.request.method': req.method,
'http.route': req.path,
};
recordHttpRequestStart(attributes);
res.on('finish', () => {
recordHttpRequestEnd(Date.now() - start, {
...attributes,
'http.response.status_code': res.statusCode,
});
});
next();
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
const swaggerConfig = new DocumentBuilder()
.setTitle('External Services Gateway')
.setDescription(
'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' +
'per-user inquiry access, audit logging, and normalized inquiry responses.',
)
.setVersion('1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'bearer',
)
.addApiKey({ type: 'apiKey', name: API_KEY_HEADER, in: 'header' }, 'api-key')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
await app.listen(port);
Logger.log(`Inquiry Gateway running on http://localhost:${port}`);
Logger.log(`Swagger docs: http://localhost:${port}/api/docs`);
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
Logger.log(`Received ${signal}, shutting down`);
await app.close();
await shutdownTelemetry();
process.exit(0);
};
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}
bootstrap().catch(async (error: unknown) => {
Logger.error('Application failed to start', error instanceof Error ? error.stack : String(error));
await shutdownTelemetry();
process.exit(1);
});

87
src/otel-nest-logger.ts Normal file
View File

@@ -0,0 +1,87 @@
import { ConsoleLogger, LogLevel } from '@nestjs/common';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
const nestLogger = logs.getLogger('nestjs', '1.0.0');
function stringifyLogValue(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Error) return value.stack ?? value.message;
try {
return typeof value === 'object' ? JSON.stringify(value) : String(value);
} catch {
return String(value);
}
}
function getContext(optionalParams: unknown[]): string | undefined {
const lastParam = optionalParams[optionalParams.length - 1];
return typeof lastParam === 'string' ? lastParam : undefined;
}
function emitNestLog(
severityNumber: SeverityNumber,
severityText: string,
message: unknown,
optionalParams: unknown[],
): void {
const context = getContext(optionalParams);
const attributes: Record<string, string> = {
'log.source': 'nestjs',
};
if (context) {
attributes['nestjs.context'] = context;
}
const stack = optionalParams.find(
(param) => typeof param === 'string' && param.includes('\n'),
);
if (typeof stack === 'string') {
attributes['exception.stacktrace'] = stack;
}
nestLogger.emit({
severityNumber,
severityText,
body: stringifyLogValue(message),
attributes,
});
}
export class OpenTelemetryNestLogger extends ConsoleLogger {
log(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.INFO, 'INFO', message, optionalParams);
super.log(message, ...optionalParams);
}
error(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.ERROR, 'ERROR', message, optionalParams);
super.error(message, ...optionalParams);
}
warn(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.WARN, 'WARN', message, optionalParams);
super.warn(message, ...optionalParams);
}
debug(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.DEBUG, 'DEBUG', message, optionalParams);
super.debug(message, ...optionalParams);
}
verbose(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.TRACE, 'VERBOSE', message, optionalParams);
super.verbose(message, ...optionalParams);
}
fatal(message: unknown, ...optionalParams: unknown[]): void {
emitNestLog(SeverityNumber.FATAL, 'FATAL', message, optionalParams);
super.fatal(message, ...optionalParams);
}
setLogLevels(levels: LogLevel[]): void {
super.setLogLevels(levels);
}
}

View File

@@ -0,0 +1,152 @@
import { Logger } from '@nestjs/common';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import {
InquiryProvider,
ProviderExecutionContext,
} from '../../common/interfaces/inquiry-provider.interface';
import { withRetry } from '../../common/helpers/retry.helper';
import { withTimeout } from '../../common/helpers/timeout.helper';
import { RequestLogger } from '../../common/helpers/request-logger.helper';
import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
/**
* Abstract base for all provider adapters.
*
* Responsibilities:
* - Retry + timeout orchestration
* - Standardized error normalization
* - Response formatting hooks
* - Structured logging
*
* Subclasses implement provider-specific HTTP/business logic only.
*/
export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
implements InquiryProvider<TRequest, TResponse>
{
abstract readonly name: ProviderName;
abstract readonly supportedInquiryTypes: InquiryType[];
protected readonly logger: RequestLogger;
protected readonly nestLogger: Logger;
constructor(protected readonly config: ProviderConfigSlice) {
this.logger = new RequestLogger(this.constructor.name);
this.nestLogger = new Logger(this.constructor.name);
}
isEnabled(): boolean {
return this.config.enabled;
}
async execute(
inquiryType: InquiryType,
payload: TRequest,
context: ProviderExecutionContext,
): Promise<TResponse> {
if (!this.supportedInquiryTypes.includes(inquiryType)) {
throw this.normalizeError({
code: 'UNSUPPORTED_INQUIRY',
message: `Provider ${this.name} does not support ${inquiryType}`,
});
}
const start = Date.now();
this.logger.logStart(
{
requestId: context.requestId,
trackingCode: context.trackingCode,
provider: this.name,
inquiryType,
},
'Provider execution started',
);
try {
const result = await this.executeWithResilience(
() => this.callProvider(inquiryType, payload, context),
context,
);
this.logger.logSuccess(
{
requestId: context.requestId,
trackingCode: context.trackingCode,
provider: this.name,
inquiryType,
durationMs: Date.now() - start,
},
'Provider execution succeeded',
);
return result;
} catch (error) {
this.logger.logFailure(
{
requestId: context.requestId,
trackingCode: context.trackingCode,
provider: this.name,
inquiryType,
durationMs: Date.now() - start,
},
'Provider execution failed',
error,
);
throw error;
}
}
protected async executeWithResilience<T>(
fn: () => Promise<T>,
context: ProviderExecutionContext,
): Promise<T> {
const operation = () =>
withTimeout(fn(), this.config.timeout, `${this.name} request`);
return withRetry(operation, {
maxAttempts: this.config.maxRetries,
delayMs: 300,
shouldRetry: (error) => this.isRetryable(error),
});
}
protected isRetryable(error: unknown): boolean {
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
return code === 'ETIMEDOUT' || code === 'ECONNABORTED' || code === 'ECONNRESET';
}
return false;
}
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
return {
code: partial.code ?? 'PROVIDER_ERROR',
message: partial.message ?? 'Provider request failed',
providerMessage: partial.providerMessage,
providerCode: partial.providerCode,
};
}
protected formatProviderError(
providerMessage?: string,
providerCode?: string,
fallbackMessage = 'Provider returned an error',
): Error {
const normalized = this.normalizeError({
code: 'PROVIDER_ERROR',
message: fallbackMessage,
providerMessage,
providerCode,
});
const err = new Error(normalized.message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
protected abstract callProvider(
inquiryType: InquiryType,
payload: TRequest,
context: ProviderExecutionContext,
): Promise<TResponse>;
}

View File

@@ -0,0 +1,6 @@
export class AuthTokenDto {
accessToken!: string;
refreshToken?: string;
tokenType!: string;
expiresIn!: number;
}

View File

@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { InquiryProvider } from '../../common/interfaces/inquiry-provider.interface';
import { InquiryRoutingConfig } from '../../config/configuration';
import { HamtaProvider } from '../implementations/hamta.provider';
import { MoallemProvider } from '../implementations/moallem.provider';
import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
/**
* Factory + registry for provider instances.
* Resolves providers by name and builds ordered execution chains (default + fallbacks).
* Note: AMITIS is not a provider, it's an authentication service used by other providers.
*/
@Injectable()
export class ProviderFactory {
private readonly logger = new Logger(ProviderFactory.name);
private readonly registry: Map<ProviderName, InquiryProvider>;
constructor(
private readonly configService: ConfigService,
hamta: HamtaProvider,
moallem: MoallemProvider,
tejaratnou: TejaratNouProvider,
) {
this.registry = new Map<ProviderName, InquiryProvider>([
[ProviderName.HAMTA, hamta],
[ProviderName.MOALLEM, moallem],
[ProviderName.TEJARATNOU, tejaratnou],
]);
}
getProvider(name: ProviderName): InquiryProvider | undefined {
const provider = this.registry.get(name);
if (!provider?.isEnabled()) {
this.logger.warn(`Provider ${name} is disabled or not configured`);
return undefined;
}
return provider;
}
/**
* Returns providers in execution order: default first, then fallbacks.
*/
getProvidersForInquiry(inquiryType: InquiryType): InquiryProvider[] {
const routing = this.configService.get<InquiryRoutingConfig>(
`inquiryRouting.${inquiryType}`,
);
if (!routing) {
this.logger.error(`No routing config for inquiry type ${inquiryType}`);
return [];
}
const orderedNames = [
routing.defaultProvider,
...(routing.fallbackEnabled ? routing.fallbackProviders : []),
];
const seen = new Set<ProviderName>();
const providers: InquiryProvider[] = [];
for (const name of orderedNames) {
if (seen.has(name)) continue;
seen.add(name);
const provider = this.getProvider(name);
if (provider?.supportedInquiryTypes.includes(inquiryType)) {
providers.push(provider);
}
}
return providers;
}
}

View File

@@ -0,0 +1,240 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { AmitisAuthServiceConfig } from '../../config/configuration';
import { GeneralTokenDocument } from '../schemas/general-token.schema';
import { GeneralTokenService } from '../services/general-token.service';
interface CentInsurTokenResponse {
Token?: string;
token?: string;
AccessToken?: string;
accessToken?: string;
access_token?: string;
RefreshToken?: string;
refreshToken?: string;
refresh_token?: string;
TokenType?: string;
tokenType?: string;
token_type?: string;
ExpiresIn?: number | string;
expiresIn?: number | string;
expires_in?: number | string;
data?: CentInsurTokenResponse;
}
interface AmitisAuthToken {
accessToken: string;
refreshToken?: string;
tokenType: string;
expiresIn: number;
}
/**
* AMITIS Authentication Service
* Provides token-based authentication for Hamta and Moallem providers
* This is NOT a provider itself, but an authentication helper service
*/
@Injectable()
export class AmitisProvider {
readonly name = ProviderName.AMITIS;
private readonly logger = new Logger(AmitisProvider.name);
private readonly httpClient: AxiosInstance;
private readonly config: AmitisAuthServiceConfig;
constructor(
configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
this.httpClient = axios.create({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
}
isEnabled(): boolean {
return this.config.enabled && Boolean(this.config.baseUrl);
}
/**
* Get access token for a specific provider and inquiry type
* Handles token caching, refresh, and daily expiration
*/
async getAccessToken(
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
): Promise<string> {
const tokenKey = this.getTokenKey(providerName, inquiryType);
const latestToken = await this.generalTokenService.getLatestToken(tokenKey);
// Use cached token if valid and from same day
if (latestToken && !this.isExpired(latestToken) && this.isSameTokenDay(latestToken)) {
return latestToken.accessToken;
}
// Try to refresh if we have a refresh token and it's from the same day
if (latestToken?.refreshToken && this.isSameTokenDay(latestToken)) {
try {
return await this.refreshAccessToken(latestToken, providerName, inquiryType);
} catch (error) {
this.logger.warn(
`AMITIS token refresh failed for ${providerName}/${inquiryType}, requesting a new token: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// Login to get new token
return this.login(providerName, inquiryType, username, password);
}
private async login(
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
): Promise<string> {
// Use URLSearchParams for application/x-www-form-urlencoded format
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
try {
const response = await this.httpClient.post<CentInsurTokenResponse>(
this.config.loginPath,
formData.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
const token = this.extractToken(response.data);
await this.saveToken(token, providerName, inquiryType, username, 'login');
return token.accessToken;
} catch (error) {
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
}
}
private async refreshAccessToken(
latestToken: GeneralTokenDocument,
providerName: ProviderName,
inquiryType: InquiryType,
): Promise<string> {
try {
const response = await this.httpClient.post<CentInsurTokenResponse>(this.config.refreshPath, {
Token: latestToken.accessToken,
RefreshToken: latestToken.refreshToken,
});
const token = this.extractToken(response.data, latestToken.refreshToken);
await this.saveToken(
token,
providerName,
inquiryType,
latestToken.username,
'refresh',
);
return token.accessToken;
} catch (error) {
throw this.toAuthError(
`AMITIS refresh failed for ${providerName}/${inquiryType}`,
error,
);
}
}
private async saveToken(
token: AmitisAuthToken,
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
scope: string,
): Promise<void> {
await this.generalTokenService.create({
serviceProvider: this.getTokenKey(providerName, inquiryType),
tokenType: token.tokenType,
url: this.config.baseUrl,
clientId: '',
clientSecret: '',
username,
scope,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
expiresIn: token.expiresIn,
expiresAt: new Date(Date.now() + token.expiresIn * 1000),
});
}
private extractToken(
responseBody: CentInsurTokenResponse,
fallbackRefreshToken?: string,
): AmitisAuthToken {
const body = responseBody.data ?? responseBody;
const accessToken =
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
if (!accessToken) {
throw new Error('AMITIS auth response did not include an access token');
}
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
return {
accessToken,
refreshToken:
body.RefreshToken ?? body.refreshToken ?? body.refresh_token ?? fallbackRefreshToken,
tokenType: body.TokenType ?? body.tokenType ?? body.token_type ?? 'Bearer',
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 20 * 60,
};
}
private isExpired(token: GeneralTokenDocument): boolean {
const refreshBufferMs = 60 * 1000;
return !token.expiresAt || Date.now() + refreshBufferMs >= token.expiresAt.getTime();
}
private isSameTokenDay(token: GeneralTokenDocument): boolean {
if (!token.createdAt) {
return false;
}
return this.getDateKey(token.createdAt) === this.getDateKey(new Date());
}
private getDateKey(date: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: this.config.tokenTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
private getTokenKey(providerName: ProviderName, inquiryType: InquiryType): string {
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
}
private toAuthError(message: string, error: unknown): Error {
if (error instanceof AxiosError) {
const status = error.response?.status ?? 'NETWORK_ERROR';
return new Error(`${message}: ${status} ${error.message}`);
}
if (error instanceof Error) {
return new Error(`${message}: ${error.message}`);
}
return new Error(`${message}: ${String(error)}`);
}
}
// Made with Bob

View File

@@ -0,0 +1,165 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration';
import { AmitisProvider } from './amitis.provider';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import {
LegacyInquiryPayload,
LegacyInquiryResult,
LegacyApiProvider,
} from '../shared/legacy-api.provider.abstract';
interface ShahkarInquiryPayload extends LegacyInquiryPayload {
nationalCode?: string;
nationalCod?: string;
NationalCod?: string;
mobileNo?: string;
MobileNo?: string;
}
/**
* Hamta provider — uses shared LegacyApiProvider base.
* Supports multiple inquiry types with different authentication methods.
*/
@Injectable()
export class HamtaProvider extends LegacyApiProvider {
readonly name = ProviderName.HAMTA;
readonly supportedInquiryTypes = [
InquiryType.PERSON,
InquiryType.REAL_ESTATE,
InquiryType.SHEBA,
InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE,
InquiryType.LEGAL_PERSON,
];
private readonly hamtaConfig: ProviderEnvConfig;
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
const config = configService.get<ProviderEnvConfig>('hamta')!;
super(
config,
(providerName, inquiryType, username, password) =>
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
);
this.hamtaConfig = config;
}
protected async callProvider(
inquiryType: InquiryType,
payload: LegacyInquiryPayload,
context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> {
// Shahkar uses SOAP authentication, handled separately
if (inquiryType === InquiryType.SHAHKAR) {
return this.inquireShahkar(payload as ShahkarInquiryPayload);
}
return super.callProvider(inquiryType, payload, context);
}
private async inquireShahkar(payload: ShahkarInquiryPayload): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
'nationalCode',
);
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildShahkarEnvelope(
nationalCode,
mobileNo,
inquiryConfig.username,
inquiryConfig.password,
),
{
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
},
timeout: this.hamtaConfig.timeout,
responseType: 'text',
},
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
return {
raw: {
nationalCode,
mobileNo,
result,
soap: response.data,
},
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private buildShahkarEnvelope(
nationalCode: string,
mobileNo: string,
username: string,
password: string,
): string {
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<ShahkarInquery xmlns="http://tempuri.org/">
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</ShahkarInquery>
</soap:Body>
</soap:Envelope>`;
}
private extractSoapValue(xml: string, tagName: string): string | null {
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
return match ? this.decodeXml(match[1].trim()) : null;
}
private getRequiredString(value: unknown, fieldName: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
}
return value.trim();
}
private escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
}

View File

@@ -0,0 +1,360 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { AmitisProvider } from './amitis.provider';
import {
LegacyApiProvider,
LegacyInquiryPayload,
LegacyInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface CivilRegistrationPayload extends LegacyInquiryPayload {
nationalCode?: string;
NIN?: string;
birthDate?: string;
BirthDate?: string;
dateHasPostfix?: number;
}
interface PostalCodePayload extends LegacyInquiryPayload {
postalCode?: string;
PostalCode?: string;
}
interface ShahkarPayload extends LegacyInquiryPayload {
nationalCode?: string;
nationalCod?: string;
NationalCod?: string;
mobileNo?: string;
MobileNo?: string;
}
interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string;
nationalId?: string;
legalId?: string | null;
shebaId?: string;
}
/**
* Moallem provider — implements Moallem/CentInsur REST and SOAP inquiry services.
*/
@Injectable()
export class MoallemProvider extends LegacyApiProvider {
readonly name = ProviderName.MOALLEM;
readonly supportedInquiryTypes = [
InquiryType.PERSON,
InquiryType.SHEBA,
InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE,
];
private readonly moallemConfig: ProviderEnvConfig;
constructor(configService: ConfigService, private readonly amitisProvider: AmitisProvider) {
const config = configService.get<ProviderEnvConfig>('moallem')!;
super(
config,
(providerName, inquiryType, username, password) =>
amitisProvider.getAccessToken(providerName, inquiryType, username, password),
);
this.moallemConfig = config;
}
protected async callProvider(
inquiryType: InquiryType,
payload: LegacyInquiryPayload,
context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> {
if (inquiryType === InquiryType.PERSON) {
return this.inquireCivilRegistration(payload as CivilRegistrationPayload);
}
if (inquiryType === InquiryType.POSTAL_CODE) {
return this.inquirePostalCode(payload as PostalCodePayload);
}
if (inquiryType === InquiryType.SHAHKAR) {
return this.inquireShahkar(payload as ShahkarPayload);
}
if (inquiryType === InquiryType.SHEBA) {
return this.inquireSayah(payload as SayahPayload);
}
return super.callProvider(inquiryType, payload, context);
}
private async inquirePostalCode(payload: PostalCodePayload): Promise<{ raw: unknown }> {
const postalCode = this.getRequiredString(
payload.postalCode ?? payload.PostalCode,
'postalCode',
);
const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE);
const token = await this.amitisProvider.getAccessToken(
ProviderName.MOALLEM,
InquiryType.POSTAL_CODE,
inquiryConfig.username,
inquiryConfig.password,
);
try {
const response = await axios.get<Record<string, unknown>>(
`${inquiryConfig.url}/AddressByPostcode`,
{
params: { PostalCode: postalCode },
headers: { Authorization: `Bearer ${token}` },
timeout: this.moallemConfig.timeout,
},
);
return { raw: response.data };
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireCivilRegistration(
payload: CivilRegistrationPayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN,
'nationalCode',
);
const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate');
const inquiryConfig = this.getInquiryConfig(InquiryType.PERSON);
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCivilRegistrationEnvelope(
nationalCode,
birthDate,
inquiryConfig.username,
inquiryConfig.password,
),
{
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"',
},
timeout: this.moallemConfig.timeout,
responseType: 'text',
},
);
const result = this.extractSoapValue(response.data, 'SabtInqueryResult');
return {
raw: {
nationalCode,
birthDate,
result,
soap: response.data,
},
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireShahkar(payload: ShahkarPayload): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
'nationalCode',
);
const mobileNo = this.getRequiredString(payload.mobileNo ?? payload.MobileNo, 'mobileNo');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHAHKAR);
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildShahkarEnvelope(
nationalCode,
mobileNo,
inquiryConfig.username,
inquiryConfig.password,
),
{
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
},
timeout: this.moallemConfig.timeout,
responseType: 'text',
},
);
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult');
return {
raw: {
nationalCode,
mobileNo,
result,
soap: response.data,
},
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType');
const nationalId = payload.nationalId ?? '';
const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
const token = await this.amitisProvider.getAccessToken(
ProviderName.MOALLEM,
InquiryType.SHEBA,
inquiryConfig.username,
inquiryConfig.password,
);
try {
const response = await axios.post<{
IsSucceed?: boolean;
Errors?: Array<{ Code?: string; Message?: string }>;
Result?: unknown;
}>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
NationalId: nationalId,
LegalId: legalId,
ShebaId: shebaId,
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.moallemConfig.timeout,
},
);
if (!response.data.IsSucceed && response.data.Errors) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors),
'SAYAH_HAS_ERROR',
);
}
return { raw: response.data };
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private buildCivilRegistrationEnvelope(
nationalCode: string,
birthDate: string,
username: string,
password: string,
): string {
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SabtInquery xmlns="http://tempuri.org/">
<NIN>${this.escapeXml(nationalCode)}</NIN>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</SabtInquery>
</soap:Body>
</soap:Envelope>`;
}
private buildShahkarEnvelope(
nationalCode: string,
mobileNo: string,
username: string,
password: string,
): string {
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<ShahkarInquery xmlns="http://tempuri.org/">
<NationalCod>${this.escapeXml(nationalCode)}</NationalCod>
<MobileNo>${this.escapeXml(mobileNo)}</MobileNo>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</ShahkarInquery>
</soap:Body>
</soap:Envelope>`;
}
private extractSoapValue(xml: string, tagName: string): string | null {
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
return match ? this.decodeXml(match[1].trim()) : null;
}
private getRequiredString(value: unknown, fieldName: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw this.formatProviderError(undefined, undefined, `${fieldName} is required`);
}
return value.trim();
}
private formatErrors(errors: Array<{ Code?: string; Message?: string }>): string {
return errors.map((e) => `${e.Code}: ${e.Message}`).join(', ');
}
private escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
}
// Made with Bob

View File

@@ -0,0 +1,262 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
import { GeneralTokenService } from '../services/general-token.service';
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
import { PersonInquiryPayload, PersonInquiryResult } from '../shared/legacy-api.provider.abstract';
interface TejaratNouPersonInquiryResponse {
data?: TejaratNouPersonInquiryData;
isSuccess?: boolean;
statusCode?: number;
message?: string;
}
interface TejaratNouPersonInquiryData {
nationalCode?: number | string;
nationalCodeString?: string;
name?: string;
family?: string;
fatherName?: string;
shenasnameSeri?: string;
shenasnameSerial?: number | string;
shenasnameNo?: number | string;
birthDate?: number | string;
birthDateGregorian?: string;
gender?: unknown;
deathStatus?: unknown;
deathDate?: string;
zipcode?: string;
zipcodeDesc?: string;
exceptionMessage?: string;
message?: unknown;
}
@Injectable()
export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload, PersonInquiryResult> {
readonly name = ProviderName.TEJARATNOU;
readonly supportedInquiryTypes = [InquiryType.PERSON];
private readonly logger = new Logger(TejaratNouProvider.name);
private readonly httpClient: AxiosInstance;
private readonly config: TejaratNouConfig;
constructor(
private readonly configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
private readonly cachedInquiryResultService: CachedInquiryResultService,
) {
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
this.httpClient = axios.create({
baseURL: this.config.inquiryBaseUrl,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
}
isEnabled(): boolean {
return this.config.enabled && Boolean(this.config.baseUrl) && Boolean(this.config.inquiryBaseUrl);
}
async execute(
inquiryType: InquiryType,
payload: PersonInquiryPayload,
context: ProviderExecutionContext,
): Promise<PersonInquiryResult> {
if (inquiryType !== InquiryType.PERSON) {
throw new Error(`Unsupported inquiry type: ${inquiryType}`);
}
return this.inquirePerson(payload, context);
}
private async inquirePerson(
payload: PersonInquiryPayload,
_context: ProviderExecutionContext,
): Promise<PersonInquiryResult> {
const cachedResult = await this.cachedInquiryResultService.findByNationalCodeAndBirthDate(
payload.nationalCode,
payload.birthDate,
);
if (cachedResult) {
this.logger.log(`Cache hit for nationalCode: ${payload.nationalCode}`);
return {
nationalCode: cachedResult.nationalCode,
birthDate: cachedResult.birthDate,
fullName: `${cachedResult.name} ${cachedResult.family}`,
raw: cachedResult as any,
};
}
const token = await this.getTniToken();
if (!token) {
throw new Error('Failed to retrieve TNI token');
}
try {
const providerBirthDate = jalaliDateToGregorianDate(payload.birthDate);
const response = await this.httpClient.post<TejaratNouPersonInquiryResponse>(
`/api/identity-inquiry/national-code/${payload.nationalCode}/birthdate/${encodeURIComponent(providerBirthDate)}`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'text/plain',
},
},
);
if (!response.data.isSuccess || !response.data.data) {
throw this.createProviderError(
response.data.message ?? 'TejaratNou person inquiry failed',
String(response.data.statusCode ?? 'PROVIDER_ERROR'),
);
}
const inquiryData = response.data.data;
const nationalCode = this.getNationalCode(inquiryData);
await this.cacheInquiryResult(inquiryData, payload.birthDate);
return {
nationalCode,
birthDate: payload.birthDate,
fullName: this.getFullName(inquiryData),
raw: inquiryData,
};
} catch (error) {
if (error instanceof AxiosError) {
this.logger.error(
`TejaratNou person inquiry failed: ${error.response?.status ?? 'NETWORK_ERROR'} ${error.message}`,
);
} else {
this.logger.error(`TejaratNou person inquiry failed: ${error}`);
}
throw error;
}
}
private async cacheInquiryResult(
inquiryData: TejaratNouPersonInquiryData,
gatewayBirthDate: string,
): Promise<void> {
try {
await this.cachedInquiryResultService.create({
nationalCode: this.getNationalCode(inquiryData),
name: String(inquiryData.name ?? ''),
family: String(inquiryData.family ?? ''),
fatherName: String(inquiryData.fatherName ?? ''),
shenasnameSeri: String(inquiryData.shenasnameSeri ?? ''),
shenasnameSerial: String(inquiryData.shenasnameSerial ?? ''),
shenasnameNo: String(inquiryData.shenasnameNo ?? ''),
birthDate: gatewayBirthDate,
birthDateGregorian: inquiryData.birthDateGregorian,
gender: inquiryData.gender,
deathStatus: inquiryData.deathStatus,
deathDate:
inquiryData.deathDate === undefined || inquiryData.deathDate === null
? undefined
: String(inquiryData.deathDate),
zipcode: String(inquiryData.zipcode ?? ''),
zipcodeDesc: inquiryData.zipcodeDesc,
exceptionMessage:
inquiryData.exceptionMessage === undefined || inquiryData.exceptionMessage === null
? undefined
: String(inquiryData.exceptionMessage),
message: inquiryData.message,
});
} catch (error) {
this.logger.warn(
`TejaratNou inquiry succeeded, but cache write failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
private getNationalCode(inquiryData: TejaratNouPersonInquiryData): string {
return String(inquiryData.nationalCodeString ?? inquiryData.nationalCode ?? '');
}
private getFullName(inquiryData: TejaratNouPersonInquiryData): string {
return [inquiryData.name, inquiryData.family].filter(Boolean).join(' ');
}
private createProviderError(providerMessage: string, providerCode: string): Error {
const error = new Error(providerMessage);
(
error as Error & {
normalizedError: {
code: string;
message: string;
providerMessage: string;
providerCode: string;
};
}
).normalizedError = {
code: 'PROVIDER_ERROR',
message: providerMessage,
providerMessage,
providerCode,
};
return error;
}
private async getTniToken(): Promise<string | null> {
const latestToken = await this.generalTokenService.getLatestToken();
if (latestToken && !(await this.generalTokenService.isTokenExpired(latestToken))) {
this.logger.log(`Using existing token: ${latestToken._id}`);
return latestToken.accessToken;
}
try {
const response = await axios.post(
`${this.config.baseUrl}/connect/token`,
new URLSearchParams({
grant_type: 'password',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
username: this.config.username,
password: this.config.password,
scope: 'api-gateway access-management',
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C',
},
timeout: this.config.timeout,
},
);
const jsonData = response.data;
const expiresIn = jsonData.expires_in;
const expiresAt = new Date(Date.now() + expiresIn * 1000);
await this.generalTokenService.create({
serviceProvider: 'TejaratNou',
tokenType: jsonData.token_type,
url: this.config.baseUrl,
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
username: this.config.username,
scope: jsonData.scope,
accessToken: jsonData.access_token,
expiresIn: expiresIn,
expiresAt: expiresAt,
});
return jsonData.access_token;
} catch (err) {
this.logger.error(`Failed to get TNI token: ${err}`);
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
import { ProviderEnvConfig } from '../../config/configuration';
export interface LegacyProviderApiResponse {
success?: boolean;
message?: string;
code?: string;
data?: Record<string, unknown>;
// CentInsur API format
IsSucceed?: boolean;
Result?: {
Result?: boolean;
ErrorMessage?: string | null;
ExternalServiceResponseDuration?: number;
[key: string]: unknown;
};
TrackingCode?: string;
}
export type ProviderConfigSlice = ProviderEnvConfig;

View File

@@ -0,0 +1,10 @@
export interface TejaratNouConfig {
baseUrl: string;
inquiryBaseUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
timeout: number;
enabled: boolean;
}

View File

@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { ProviderFactory } from './factory/provider.factory';
import { HamtaProvider } from './implementations/hamta.provider';
import { MoallemProvider } from './implementations/moallem.provider';
import { TejaratNouProvider } from './implementations/tejaratnou.provider';
import { AmitisProvider } from './implementations/amitis.provider';
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
import { GeneralToken, GeneralTokenSchema } from './schemas/general-token.schema';
import { CachedInquiryResult, CachedInquiryResultSchema } from './schemas/cached-inquiry-result.schema';
import { GeneralTokenService } from './services/general-token.service';
import { CachedInquiryResultService } from './services/cached-inquiry-result.service';
/**
* Provider adapters, factory, and orchestration strategy.
*/
@Module({
imports: [
MongooseModule.forFeature([
{ name: GeneralToken.name, schema: GeneralTokenSchema },
{ name: CachedInquiryResult.name, schema: CachedInquiryResultSchema },
]),
],
providers: [
HamtaProvider,
MoallemProvider,
TejaratNouProvider,
AmitisProvider,
GeneralTokenService,
CachedInquiryResultService,
ProviderFactory,
ProviderOrchestratorService,
],
exports: [ProviderFactory, ProviderOrchestratorService, GeneralTokenService, CachedInquiryResultService],
})
export class ProvidersModule {}

View File

@@ -0,0 +1,59 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Schema as MongooseSchema } from 'mongoose';
export type CachedInquiryResultDocument = HydratedDocument<CachedInquiryResult>;
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'cached_inquiry_results' })
export class CachedInquiryResult {
@Prop({ required: true, index: true })
nationalCode!: string;
@Prop({ required: true, index: true })
birthDate!: string;
@Prop()
birthDateGregorian?: string;
@Prop({ required: true })
name!: string;
@Prop({ required: true })
family!: string;
@Prop({ required: true })
fatherName!: string;
@Prop({ required: true })
shenasnameSeri!: string;
@Prop({ required: true })
shenasnameSerial!: string;
@Prop({ required: true })
shenasnameNo!: string;
@Prop({ type: MongooseSchema.Types.Mixed })
gender?: unknown;
@Prop({ type: MongooseSchema.Types.Mixed })
deathStatus?: unknown;
@Prop()
deathDate?: string;
@Prop({ required: true })
zipcode!: string;
@Prop()
zipcodeDesc?: string;
@Prop()
exceptionMessage?: string;
@Prop({ type: MongooseSchema.Types.Mixed })
message?: unknown;
createdAt?: Date;
}
export const CachedInquiryResultSchema = SchemaFactory.createForClass(CachedInquiryResult);

View File

@@ -0,0 +1,44 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type GeneralTokenDocument = HydratedDocument<GeneralToken>;
@Schema({ timestamps: { createdAt: true, updatedAt: false }, collection: 'general_tokens' })
export class GeneralToken {
@Prop({ required: true })
serviceProvider!: string;
@Prop({ required: true })
tokenType!: string;
@Prop({ required: true })
url!: string;
@Prop({ required: true })
clientId!: string;
@Prop({ required: true })
clientSecret!: string;
@Prop({ required: true })
username!: string;
@Prop({ required: true })
scope!: string;
@Prop({ required: true })
accessToken!: string;
@Prop()
refreshToken?: string;
@Prop({ required: true })
expiresIn!: number;
@Prop({ required: true })
expiresAt!: Date;
createdAt?: Date;
}
export const GeneralTokenSchema = SchemaFactory.createForClass(GeneralToken);

Some files were not shown because too many files have changed in this diff Show More