forked from Shared/esg
Merge pull request 'initial commit' (#1) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#1
This commit is contained in:
55
.env.example
Normal file
55
.env.example
Normal 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
253
README.md
@@ -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
628
docs/API_DOCUMENTATION.md
Normal 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
|
||||||
463
docs/ENVIRONMENT_CONFIGURATION.md
Normal file
463
docs/ENVIRONMENT_CONFIGURATION.md
Normal 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
9
nest-cli.json
Normal 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
12359
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
91
package.json
Normal file
91
package.json
Normal 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
43
src/app.module.ts
Normal 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
12
src/audit/audit.module.ts
Normal 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 {}
|
||||||
37
src/audit/audit.service.ts
Normal file
37
src/audit/audit.service.ts
Normal 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 ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/audit/schemas/audit-log.schema.ts
Normal file
37
src/audit/schemas/audit-log.schema.ts
Normal 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 });
|
||||||
69
src/auth/auth.controller.ts
Normal file
69
src/auth/auth.controller.ts
Normal 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
54
src/auth/auth.module.ts
Normal 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
158
src/auth/auth.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/auth/constants/auth.constants.ts
Normal file
4
src/auth/constants/auth.constants.ts
Normal 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';
|
||||||
13
src/auth/decorators/current-user.decorator.ts
Normal file
13
src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
10
src/auth/decorators/inquiry-access.decorator.ts
Normal file
10
src/auth/decorators/inquiry-access.decorator.ts
Normal 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);
|
||||||
6
src/auth/decorators/roles.decorator.ts
Normal file
6
src/auth/decorators/roles.decorator.ts
Normal 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);
|
||||||
15
src/auth/dto/auth-tokens.dto.ts
Normal file
15
src/auth/dto/auth-tokens.dto.ts
Normal 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
15
src/auth/dto/login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
62
src/auth/dto/profile.dto.ts
Normal file
62
src/auth/dto/profile.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/auth/dto/refresh-token.dto.ts
Normal file
9
src/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
34
src/auth/guards/api-key.guard.ts
Normal file
34
src/auth/guards/api-key.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/auth/guards/inquiry-access.guard.ts
Normal file
45
src/auth/guards/inquiry-access.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/auth/guards/jwt-auth.guard.ts
Normal file
21
src/auth/guards/jwt-auth.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/auth/guards/roles.guard.ts
Normal file
34
src/auth/guards/roles.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/auth/interfaces/authenticated-user.interface.ts
Normal file
18
src/auth/interfaces/authenticated-user.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
13
src/auth/interfaces/jwt-payload.interface.ts
Normal file
13
src/auth/interfaces/jwt-payload.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/auth/services/password.service.ts
Normal file
21
src/auth/services/password.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/auth/services/token.service.ts
Normal file
50
src/auth/services/token.service.ts
Normal 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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/auth/strategies/jwt.strategy.ts
Normal file
48
src/auth/strategies/jwt.strategy.ts
Normal 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
26
src/cli/cli.module.ts
Normal 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 {}
|
||||||
66
src/cli/create-super-admin.service.ts
Normal file
66
src/cli/create-super-admin.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/cli/create-super-admin.ts
Normal file
72
src/cli/create-super-admin.ts
Normal 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();
|
||||||
20
src/common/common.module.ts
Normal file
20
src/common/common.module.ts
Normal 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 {}
|
||||||
3
src/common/constants/app.constants.ts
Normal file
3
src/common/constants/app.constants.ts
Normal 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';
|
||||||
8
src/common/decorators/request-id.decorator.ts
Normal file
8
src/common/decorators/request-id.decorator.ts
Normal 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';
|
||||||
|
},
|
||||||
|
);
|
||||||
29
src/common/dto/base-inquiry-response.dto.ts
Normal file
29
src/common/dto/base-inquiry-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
19
src/common/dto/normalized-error.dto.ts
Normal file
19
src/common/dto/normalized-error.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/common/enums/audit-event.enum.ts
Normal file
10
src/common/enums/audit-event.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
8
src/common/enums/client-type.enum.ts
Normal file
8
src/common/enums/client-type.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
5
src/common/enums/inquiry-status.enum.ts
Normal file
5
src/common/enums/inquiry-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum InquiryStatus {
|
||||||
|
SUCCESS = 'SUCCESS',
|
||||||
|
FAILURE = 'FAILURE',
|
||||||
|
PARTIAL = 'PARTIAL',
|
||||||
|
}
|
||||||
13
src/common/enums/inquiry-type.enum.ts
Normal file
13
src/common/enums/inquiry-type.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
10
src/common/enums/provider-name.enum.ts
Normal file
10
src/common/enums/provider-name.enum.ts
Normal 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',
|
||||||
|
}
|
||||||
12
src/common/enums/role.enum.ts
Normal file
12
src/common/enums/role.enum.ts
Normal 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];
|
||||||
12
src/common/exceptions/inquiry.exception.ts
Normal file
12
src/common/exceptions/inquiry.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/common/exceptions/provider.exception.ts
Normal file
11
src/common/exceptions/provider.exception.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/common/filters/all-exceptions.filter.ts
Normal file
77
src/common/filters/all-exceptions.filter.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/common/helpers/jalali-date.helper.ts
Normal file
132
src/common/helpers/jalali-date.helper.ts
Normal 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');
|
||||||
|
}
|
||||||
33
src/common/helpers/mask-payload.helper.ts
Normal file
33
src/common/helpers/mask-payload.helper.ts
Normal 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;
|
||||||
|
}
|
||||||
51
src/common/helpers/request-logger.helper.ts
Normal file
51
src/common/helpers/request-logger.helper.ts
Normal 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(' | ');
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/common/helpers/retry.helper.ts
Normal file
66
src/common/helpers/retry.helper.ts
Normal 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));
|
||||||
|
}
|
||||||
25
src/common/helpers/timeout.helper.ts
Normal file
25
src/common/helpers/timeout.helper.ts
Normal 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/common/helpers/tracking-code.helper.ts
Normal file
11
src/common/helpers/tracking-code.helper.ts
Normal 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}`;
|
||||||
|
}
|
||||||
43
src/common/interceptors/logging.interceptor.ts
Normal file
43
src/common/interceptors/logging.interceptor.ts
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/common/interceptors/request-id.interceptor.ts
Normal file
31
src/common/interceptors/request-id.interceptor.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/common/interceptors/response-transform.interceptor.ts
Normal file
21
src/common/interceptors/response-transform.interceptor.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/common/interfaces/inquiry-provider.interface.ts
Normal file
22
src/common/interfaces/inquiry-provider.interface.ts
Normal 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;
|
||||||
|
}
|
||||||
19
src/common/middleware/request-id.middleware.ts
Normal file
19
src/common/middleware/request-id.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/config/config.module.ts
Normal file
19
src/config/config.module.ts
Normal 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
190
src/config/configuration.ts
Normal 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
|
||||||
98
src/console-instrumentation.ts
Normal file
98
src/console-instrumentation.ts
Normal 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);
|
||||||
|
};
|
||||||
10
src/database/database-seeder.module.ts
Normal file
10
src/database/database-seeder.module.ts
Normal 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 {}
|
||||||
158
src/database/database-seeder.service.ts
Normal file
158
src/database/database-seeder.service.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/inquiry/dto/civil-registration-request.dto.ts
Normal file
24
src/inquiry/dto/civil-registration-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
45
src/inquiry/dto/civil-registration-response.dto.ts
Normal file
45
src/inquiry/dto/civil-registration-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/inquiry/dto/generic-inquiry-response.dto.ts
Normal file
25
src/inquiry/dto/generic-inquiry-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
12
src/inquiry/dto/person-inquiry-data.dto.ts
Normal file
12
src/inquiry/dto/person-inquiry-data.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/inquiry/dto/person-inquiry-request.dto.ts
Normal file
21
src/inquiry/dto/person-inquiry-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
27
src/inquiry/dto/person-inquiry-response.dto.ts
Normal file
27
src/inquiry/dto/person-inquiry-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
11
src/inquiry/dto/postal-code-request.dto.ts
Normal file
11
src/inquiry/dto/postal-code-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
57
src/inquiry/dto/postal-code-response.dto.ts
Normal file
57
src/inquiry/dto/postal-code-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
27
src/inquiry/dto/sayah-request.dto.ts
Normal file
27
src/inquiry/dto/sayah-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
12
src/inquiry/dto/sayah-response.dto.ts
Normal file
12
src/inquiry/dto/sayah-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/inquiry/dto/shahkar-inquiry-request.dto.ts
Normal file
17
src/inquiry/dto/shahkar-inquiry-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/inquiry/dto/shahkar-request.dto.ts
Normal file
17
src/inquiry/dto/shahkar-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/inquiry/dto/shahkar-response.dto.ts
Normal file
21
src/inquiry/dto/shahkar-response.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
140
src/inquiry/inquiry.controller.ts
Normal file
140
src/inquiry/inquiry.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/inquiry/inquiry.module.ts
Normal file
14
src/inquiry/inquiry.module.ts
Normal 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 {}
|
||||||
245
src/inquiry/inquiry.service.ts
Normal file
245
src/inquiry/inquiry.service.ts
Normal 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
24
src/logger.ts
Normal 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;
|
||||||
79
src/logging/dto/inquiry-log-query.dto.ts
Normal file
79
src/logging/dto/inquiry-log-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
414
src/logging/inquiry-log.service.ts
Normal file
414
src/logging/inquiry-log.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/logging/inquiry-logs.controller.ts
Normal file
47
src/logging/inquiry-logs.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/logging/logging.module.ts
Normal file
15
src/logging/logging.module.ts
Normal 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 {}
|
||||||
51
src/logging/schemas/inquiry-log.schema.ts
Normal file
51
src/logging/schemas/inquiry-log.schema.ts
Normal 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
88
src/main.ts
Normal 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
87
src/otel-nest-logger.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/providers/base/base-provider.abstract.ts
Normal file
152
src/providers/base/base-provider.abstract.ts
Normal 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>;
|
||||||
|
}
|
||||||
6
src/providers/dto/auth-token.dto.ts
Normal file
6
src/providers/dto/auth-token.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class AuthTokenDto {
|
||||||
|
accessToken!: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenType!: string;
|
||||||
|
expiresIn!: number;
|
||||||
|
}
|
||||||
76
src/providers/factory/provider.factory.ts
Normal file
76
src/providers/factory/provider.factory.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/providers/implementations/amitis.provider.ts
Normal file
240
src/providers/implementations/amitis.provider.ts
Normal 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
|
||||||
165
src/providers/implementations/hamta.provider.ts
Normal file
165
src/providers/implementations/hamta.provider.ts
Normal 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, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
360
src/providers/implementations/moallem.provider.ts
Normal file
360
src/providers/implementations/moallem.provider.ts
Normal 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, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Made with Bob
|
||||||
262
src/providers/implementations/tejaratnou.provider.ts
Normal file
262
src/providers/implementations/tejaratnou.provider.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/providers/interfaces/provider-config.interface.ts
Normal file
19
src/providers/interfaces/provider-config.interface.ts
Normal 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;
|
||||||
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
10
src/providers/interfaces/tejaratnou-config.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface TejaratNouConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
inquiryBaseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
timeout: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
36
src/providers/providers.module.ts
Normal file
36
src/providers/providers.module.ts
Normal 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 {}
|
||||||
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal file
59
src/providers/schemas/cached-inquiry-result.schema.ts
Normal 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);
|
||||||
44
src/providers/schemas/general-token.schema.ts
Normal file
44
src/providers/schemas/general-token.schema.ts
Normal 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
Reference in New Issue
Block a user