Compare commits

..

29 Commits

Author SHA1 Message Date
6e0870b2f1 merge upstream 2026-07-18 10:18:15 +03:30
e474f2d52d added server config from env 2026-07-18 10:17:52 +03:30
5ace0165db Merge pull request 'main' (#11) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#11
2026-07-05 12:12:13 +03:30
9fa4144949 merge upstream 2026-07-05 12:11:47 +03:30
Soheil Hajizadeh
8af8c8d6df fixed 2026-07-05 11:52:32 +03:30
73966878a2 Merge pull request 'api url' (#10) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#10
2026-07-04 14:30:58 +03:30
Soheil Hajizadeh
e7bb312db2 api url 2026-07-04 14:29:00 +03:30
62c8c6a3b4 Merge pull request 'main' (#9) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#9
2026-06-29 17:26:26 +03:30
91f43c312f merge upstream 2026-06-29 17:25:43 +03:30
45ef396466 feat: add Persian error translations and comprehensive test suite
Implement normalized error handling with Persian translations across all
exception types, replace legacy NestJS exceptions with AppException,
and add unit, integration, and smoke tests.

- Add error catalog with gateway-owned codes and Persian messages
- Introduce AppException wrapping normalized error envelopes
- Add translateError helper for automatic messageFa population
- Remove claims module and update provider error normalization
- Add unit tests for error contracts and helper functions
- Add integration tests for admin and inquiry endpoints
- Add smoke tests for real provider connectivity
- Add test support utilities (auth mocks, assertions, app factory)
2026-06-29 17:25:19 +03:30
4a65f356df Merge pull request 'main' (#8) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#8
2026-06-21 11:31:03 +03:30
63d41f4288 merge upstream 2026-06-21 11:30:49 +03:30
9ca6b61ca6 fixed authModule and rateLimit module dependency 2026-06-21 11:30:28 +03:30
e17181f22e Merge pull request 'main' (#7) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#7
2026-06-20 18:19:49 +03:30
7bdba4f3cc merge upstream 2026-06-20 18:19:10 +03:30
b3adb6f583 update the esg 2026-06-20 18:18:48 +03:30
d675ba220a Merge pull request 'main' (#6) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#6
2026-06-15 17:14:13 +03:30
539e7a4060 merge upstream 2026-06-15 17:13:47 +03:30
873fb1a1e2 parsian done 2026-06-15 17:13:21 +03:30
9467b18467 Merge pull request 'final update on moallm client' (#5) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#5
2026-06-15 10:28:29 +03:30
1b7f678538 final update on moallm client 2026-06-15 10:23:00 +03:30
fac6142483 Delete .local.env
accidently pushed
2026-06-14 16:48:37 +03:30
918aed3b34 Merge pull request 'moallem all endpoints working' (#4) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#4
2026-06-14 16:48:05 +03:30
2212f6da41 moallem all endpoints working 2026-06-14 16:42:52 +03:30
7a6e482586 Merge pull request 'main' (#3) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#3
2026-06-14 12:19:37 +03:30
b629a47f7f some error handler + error validation + realEstate bug fixed for hamta end moallem 2026-06-14 12:16:48 +03:30
2cb3340f40 merge upstream 2026-06-13 13:00:09 +03:30
0aad5a34d2 parsian inquiries implemented 2026-06-13 12:59:15 +03:30
13d6e0d1ea Merge pull request 'main' (#2) from s.hajizadeh/esg:main into main
Reviewed-on: Shared/esg#2
2026-06-10 15:27:34 +03:30
72 changed files with 4361 additions and 458 deletions

View File

@@ -1,6 +1,9 @@
# Application # Application
PORT=8085 PORT=8085
NODE_ENV=development NODE_ENV=development
ESG_SMOKE_ENABLED=false
CLIENT_URL=https://apex.mic.co.ir/esg
BASE_URL_PROD=
# MongoDB # MongoDB
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
@@ -18,13 +21,35 @@ BCRYPT_SALT_ROUNDS=12
THROTTLE_TTL=60 THROTTLE_TTL=60
THROTTLE_LIMIT=100 THROTTLE_LIMIT=100
# Route outbound provider API calls through an SSH tunnel (Termius dynamic/SOCKS forward)
# Use socks5h so DNS resolves on the remote server. Leave empty for direct access.
OUTBOUND_PROXY=socks5h://127.0.0.1:6565
# Log every outbound provider HTTP call (request URL, status, response body preview)
OUTBOUND_HTTP_DEBUG=true
# Provider routing (per inquiry type) # Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=HAMTA PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=MOALLEM,TEJARATNOU PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS=
SHEBA_DEFAULT_PROVIDER=PARSIAN
SHEBA_FALLBACK_ENABLED=false
SHEBA_FALLBACK_PROVIDERS=
CAR_PLATE_DEFAULT_PROVIDER=HAMTA CAR_PLATE_DEFAULT_PROVIDER=HAMTA
CAR_PLATE_FALLBACK_ENABLED=true CAR_PLATE_FALLBACK_ENABLED=true
CAR_PLATE_FALLBACK_PROVIDERS= CAR_PLATE_FALLBACK_PROVIDERS=
POLICY_BY_CHASSIS_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_CHASSIS_FALLBACK_ENABLED=false
POLICY_BY_CHASSIS_FALLBACK_PROVIDERS=
POLICY_BY_PLATE_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_PLATE_FALLBACK_ENABLED=false
POLICY_BY_PLATE_FALLBACK_PROVIDERS=
POLICY_BY_NATIONAL_CODE_DEFAULT_PROVIDER=PARSIAN
POLICY_BY_NATIONAL_CODE_FALLBACK_ENABLED=false
POLICY_BY_NATIONAL_CODE_FALLBACK_PROVIDERS=
# Hamta provider # Hamta provider
HAMTA_BASE_URL=https://api.hamta.example.com HAMTA_BASE_URL=https://api.hamta.example.com
@@ -34,6 +59,10 @@ HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000 HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true HAMTA_ENABLED=true
HAMTA_MAX_RETRIES=2 HAMTA_MAX_RETRIES=2
HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=
HAMTA_PERSON_PASSWORD=
HAMTA_PERSON_AUTH_METHOD=SOAP
# Moallem provider (identical API structure to Hamta) # Moallem provider (identical API structure to Hamta)
MOALLEM_BASE_URL=https://api.moallem.example.com MOALLEM_BASE_URL=https://api.moallem.example.com
@@ -44,6 +73,34 @@ MOALLEM_TIMEOUT=10000
MOALLEM_ENABLED=true MOALLEM_ENABLED=true
MOALLEM_MAX_RETRIES=2 MOALLEM_MAX_RETRIES=2
# Parsian provider
PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=
PARSIAN_PERSON_PASSWORD=
PARSIAN_PERSON_AUTH_METHOD=SOAP
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
PARSIAN_SHEBA_URL=https://sayah.services.centinsur.ir/api/Cisb/TatbighServiceAsync
PARSIAN_SHEBA_USERNAME=
PARSIAN_SHEBA_PASSWORD=
PARSIAN_SHEBA_AUTH_METHOD=AMITIS
PARSIAN_POLICY_BY_CHASSIS_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_CHASSIS_USERNAME=
PARSIAN_POLICY_BY_CHASSIS_PASSWORD=
PARSIAN_POLICY_BY_CHASSIS_AUTH_METHOD=SOAP
PARSIAN_POLICY_BY_PLATE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_PLATE_USERNAME=
PARSIAN_POLICY_BY_PLATE_PASSWORD=
PARSIAN_POLICY_BY_PLATE_AUTH_METHOD=SOAP
PARSIAN_POLICY_BY_NATIONAL_CODE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_NATIONAL_CODE_USERNAME=
PARSIAN_POLICY_BY_NATIONAL_CODE_PASSWORD=
PARSIAN_POLICY_BY_NATIONAL_CODE_AUTH_METHOD=SOAP
# TejaratNou provider # TejaratNou provider
TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir
TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir

View File

@@ -111,14 +111,14 @@ Per-inquiry configuration via environment variables:
```env ```env
# Default provider and fallback chain # Default provider and fallback chain
PERSON_DEFAULT_PROVIDER=HAMTA PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_PROVIDERS=MOALLEM PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
# Per-inquiry credentials and authentication # Per-inquiry credentials and authentication
HAMTA_PERSON_URL=https://api.example.com/person PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=user123 PARSIAN_PERSON_USERNAME=user123
HAMTA_PERSON_PASSWORD=pass123 PARSIAN_PERSON_PASSWORD=pass123
HAMTA_PERSON_AUTH_METHOD=AMITIS PARSIAN_PERSON_AUTH_METHOD=SOAP
# AMITIS authentication service # AMITIS authentication service
AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login AMITIS_LOGIN_URL=https://auth.services.centinsur.ir/api/security/login
@@ -131,13 +131,16 @@ See [Environment Configuration Guide](docs/ENVIRONMENT_CONFIGURATION.md) for com
| Inquiry Type | Providers | Description | | Inquiry Type | Providers | Description |
|--------------|-----------|-------------| |--------------|-----------|-------------|
| `PERSON_INQUIRY` | HAMTA, MOALLEM | Person information by national code and birth date | | `PERSON_INQUIRY` | PARSIAN, HAMTA, TEJARATNOU, MOALLEM | Person information by national code and birth date |
| `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information | | `REAL_ESTATE_INQUIRY` | HAMTA | Real estate ownership information |
| `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code | | `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code |
| `SHAHKAR_INQUIRY` | HAMTA, MOALLEM | Mobile number verification | | `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification |
| `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data | | `CIVIL_REGISTRATION_INQUIRY` | MOALLEM | Civil registration data |
| `SHEBA_INQUIRY` | MOALLEM | Bank account (SHEBA) validation | | `SHEBA_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Bank account (SHEBA) validation |
| `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry | | `SAYAH_INQUIRY` | TEJARATNOU | Sayah system inquiry |
| `POLICY_BY_CHASSIS_INQUIRY` | PARSIAN | Car policy history by chassis number |
| `POLICY_BY_PLATE_INQUIRY` | PARSIAN | Car policy history by national plate |
| `POLICY_BY_NATIONAL_CODE_INQUIRY` | PARSIAN | Car policy history by national code |
See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats. See [API Documentation](docs/API_DOCUMENTATION.md) for detailed request/response formats.

View File

@@ -352,9 +352,10 @@ POST /api/inquiry/sheba
### Request Body ### Request Body
```json ```json
{ {
"accountOwnerType": "REAL", "accountOwnerType": "1",
"nationalId": "0123456789", "nationalCode": "0123456789",
"shebaId": "IR123456789012345678901234" "legalId": null,
"sheba": "IR123456789012345678901234"
} }
``` ```
@@ -362,10 +363,10 @@ POST /api/inquiry/sheba
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
| accountOwnerType | string | Yes | Account owner type: "REAL" (person) or "LEGAL" (company) | | accountOwnerType | string | No | Account owner type; defaults to `"1"` in providers |
| nationalId | string | Conditional | Required if accountOwnerType is "REAL" | | nationalCode | string | Yes | Iranian national code (10 digits) |
| legalId | string | Conditional | Required if accountOwnerType is "LEGAL" | | legalId | string | No | Legal identifier when needed by the selected account owner type |
| shebaId | string | Yes | SHEBA/IBAN number (26 characters starting with IR) | | sheba | string | Yes | SHEBA/IBAN number (26 characters starting with IR) |
### Success Response (200 OK) ### Success Response (200 OK)
@@ -398,12 +399,57 @@ curl -X POST https://api.example.com/api/inquiry/sheba \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: your-api-key-here" \ -H "X-API-Key: your-api-key-here" \
-d '{ -d '{
"accountOwnerType": "REAL", "accountOwnerType": "1",
"nationalId": "0123456789", "nationalCode": "0123456789",
"shebaId": "IR123456789012345678901234" "sheba": "IR123456789012345678901234"
}' }'
``` ```
## Car Policy By Chassis
### Endpoint
```
POST /api/inquiry/policyByChassis
```
### Request Body
```json
{
"chassisNo": "NAAM01E15HK123456"
}
```
## Car Policy By Plate
### Endpoint
```
POST /api/inquiry/policyByPlate
```
### Request Body
```json
{
"plk1": "12",
"plk2": "ب",
"plk3": "345",
"plksrl": "67"
}
```
## Car Policy By National Code
### Endpoint
```
POST /api/inquiry/policyByNationalCode
```
### Request Body
```json
{
"nationalCode": "0123456789"
}
```
## Error Responses ## Error Responses
### 400 Bad Request - Invalid Input ### 400 Bad Request - Invalid Input

View File

@@ -112,11 +112,11 @@ HAMTA_ENABLED=true
HAMTA_TIMEOUT=10000 HAMTA_TIMEOUT=10000
HAMTA_MAX_RETRIES=3 HAMTA_MAX_RETRIES=3
# Person Inquiry (AMITIS auth) # Person Inquiry (SOAP auth)
HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=pa6476 HAMTA_PERSON_USERNAME=hamta.sabtahval
HAMTA_PERSON_PASSWORD=ciiws@sabt92 HAMTA_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=AMITIS HAMTA_PERSON_AUTH_METHOD=SOAP
# Real Estate Inquiry (AMITIS auth) # Real Estate Inquiry (AMITIS auth)
HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry
@@ -147,6 +147,7 @@ HAMTA_LEGAL_PERSON_URL=https://api.hamta.example.com/api/inquiry/legalPerson
HAMTA_LEGAL_PERSON_USERNAME=SabtAsnadHamta HAMTA_LEGAL_PERSON_USERNAME=SabtAsnadHamta
HAMTA_LEGAL_PERSON_PASSWORD=HSY1f6?e@kSJ HAMTA_LEGAL_PERSON_PASSWORD=HSY1f6?e@kSJ
HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS HAMTA_LEGAL_PERSON_AUTH_METHOD=AMITIS
``` ```
### MOALLEM Provider ### MOALLEM Provider
@@ -194,6 +195,50 @@ MOALLEM_LEGAL_PERSON_PASSWORD=
MOALLEM_LEGAL_PERSON_AUTH_METHOD=SOAP MOALLEM_LEGAL_PERSON_AUTH_METHOD=SOAP
``` ```
### PARSIAN Provider
```env
# Global Settings
PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
# Person Inquiry (SOAP auth)
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=pa6476
PARSIAN_PERSON_PASSWORD=your-password
PARSIAN_PERSON_AUTH_METHOD=SOAP
# Shahkar Inquiry (X-PACKAGE-API-KEY header)
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=your-package-api-key
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
# Sheba/Sayah Inquiry (AMITIS auth)
PARSIAN_SHEBA_URL=https://sayah.services.centinsur.ir/api/Cisb/TatbighServiceAsync
PARSIAN_SHEBA_USERNAME=parsian.sayah
PARSIAN_SHEBA_PASSWORD=your-password
PARSIAN_SHEBA_AUTH_METHOD=AMITIS
# Car policy inquiry by chassis (SOAP auth)
PARSIAN_POLICY_BY_CHASSIS_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_CHASSIS_USERNAME=pa6476
PARSIAN_POLICY_BY_CHASSIS_PASSWORD=your-password
PARSIAN_POLICY_BY_CHASSIS_AUTH_METHOD=SOAP
# Car policy inquiry by national plate (SOAP auth)
PARSIAN_POLICY_BY_PLATE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_PLATE_USERNAME=pa6476
PARSIAN_POLICY_BY_PLATE_PASSWORD=your-password
PARSIAN_POLICY_BY_PLATE_AUTH_METHOD=SOAP
# Car policy inquiry by national code (SOAP auth)
PARSIAN_POLICY_BY_NATIONAL_CODE_URL=http://reinsure.centinsur.ir/CarAllPlcysV4
PARSIAN_POLICY_BY_NATIONAL_CODE_USERNAME=pa6476
PARSIAN_POLICY_BY_NATIONAL_CODE_PASSWORD=your-password
PARSIAN_POLICY_BY_NATIONAL_CODE_AUTH_METHOD=SOAP
```
### TEJARATNOU Provider ### TEJARATNOU Provider
```env ```env
@@ -238,22 +283,22 @@ Routing determines which provider handles each inquiry type and whether to use f
#### Person Inquiry (Single Provider) #### Person Inquiry (Single Provider)
```env ```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=false PERSON_FALLBACK_ENABLED=false
PERSON_FALLBACK_PROVIDERS= PERSON_FALLBACK_PROVIDERS=
``` ```
**Behavior**: Only TEJARATNOU is used. If it fails, request fails. **Behavior**: Only PARSIAN is used. If it fails, request fails.
#### Person Inquiry (With Fallback) #### Person Inquiry (With Fallback)
```env ```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
``` ```
**Behavior**: **Behavior**:
1. Try TEJARATNOU first 1. Try PARSIAN first
2. If fails, try HAMTA 2. If fails, try HAMTA
3. If fails, try MOALLEM 3. If fails, try TEJARATNOU
4. If all fail, return error 4. If all fail, return error
#### Real Estate Inquiry #### Real Estate Inquiry
@@ -265,11 +310,18 @@ REAL_ESTATE_FALLBACK_PROVIDERS=
#### Shahkar Inquiry #### Shahkar Inquiry
```env ```env
SHAHKAR_DEFAULT_PROVIDER=MOALLEM SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS= SHAHKAR_FALLBACK_PROVIDERS=
``` ```
#### Sheba/Sayah Inquiry
```env
SHEBA_DEFAULT_PROVIDER=PARSIAN
SHEBA_FALLBACK_ENABLED=false
SHEBA_FALLBACK_PROVIDERS=
```
## Provider Behavior Examples ## Provider Behavior Examples
### Scenario 1: All Providers Enabled ### Scenario 1: All Providers Enabled
@@ -451,9 +503,9 @@ AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92
### New Format (Current) ### New Format (Current)
```env ```env
HAMTA_PERSON_USERNAME=pa6476 PARSIAN_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92 PARSIAN_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=AMITIS PARSIAN_PERSON_AUTH_METHOD=SOAP
``` ```
**Key Changes:** **Key Changes:**

36
jest.config.js Normal file
View File

@@ -0,0 +1,36 @@
module.exports = {
projects: [
{
displayName: 'unit',
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/unit/**/*.spec.ts'],
moduleFileExtensions: ['js', 'json', 'ts'],
},
{
displayName: 'integration',
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/test/integration/**/*.e2e-spec.ts'],
moduleFileExtensions: ['js', 'json', 'ts'],
},
{
displayName: 'admin',
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/test/admin/**/*.e2e-spec.ts'],
moduleFileExtensions: ['js', 'json', 'ts'],
},
{
displayName: 'smoke',
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['<rootDir>/test/smoke/**/*.smoke-spec.ts'],
moduleFileExtensions: ['js', 'json', 'ts'],
},
],
};

View File

@@ -3,7 +3,7 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": false,
"assets": [{ "include": "public/**/*", "outDir": "dist" }] "assets": [{ "include": "public/**/*", "outDir": "dist" }]
} }
} }

213
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3" "uuid": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -52,12 +53,14 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -2371,6 +2374,19 @@
"reflect-metadata": "^0.1.13 || ^0.2.0" "reflect-metadata": "^0.1.13 || ^0.2.0"
} }
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nuxtjs/opencollective": { "node_modules/@nuxtjs/opencollective": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz",
@@ -3815,6 +3831,16 @@
"node": "^18.19.0 || >=20.6.0" "node": "^18.19.0 || >=20.6.0"
} }
}, },
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4065,6 +4091,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz",
@@ -4200,6 +4233,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz",
@@ -4327,6 +4367,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/superagent": {
"version": "8.1.10",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/superagent/-/superagent-8.1.10.tgz",
"integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "7.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/supertest/-/supertest-7.2.0.tgz",
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/tedious": { "node_modules/@types/tedious": {
"version": "4.0.14", "version": "4.0.14",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz",
@@ -5060,6 +5124,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz",
@@ -5849,6 +5920,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz",
@@ -5924,6 +6005,13 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -6142,6 +6230,17 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz",
@@ -7235,6 +7334,24 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz",
@@ -7847,6 +7964,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -10927,6 +11053,44 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "7.0.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz",
"integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==",
"license": "MIT",
"dependencies": {
"agent-base": "^6.0.2",
"debug": "^4.3.3",
"socks": "^2.6.2"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/source-map/-/source-map-0.7.4.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/source-map/-/source-map-0.7.4.tgz",
@@ -11142,6 +11306,55 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,9 +10,14 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest --selectProjects unit integration admin",
"test:watch": "jest --watch", "test:unit": "jest --selectProjects unit",
"test:cov": "jest --coverage", "test:integration": "jest --selectProjects integration",
"test:admin": "jest --selectProjects admin",
"test:watch": "jest --selectProjects unit integration admin --watch",
"test:cov": "jest --selectProjects unit integration admin --coverage",
"smoke": "ESG_SMOKE_ENABLED=true jest --selectProjects smoke --runInBand",
"smoke:client": "npm run smoke --",
"cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts" "cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts"
}, },
"dependencies": { "dependencies": {
@@ -48,6 +53,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3" "uuid": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -59,33 +65,18 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3" "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"
} }
} }

View File

@@ -1,8 +1,4 @@
import { import { Injectable } from '@nestjs/common';
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuditEvent } from '../common/enums/audit-event.enum'; import { AuditEvent } from '../common/enums/audit-event.enum';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { UsersService, RequestContext } from '../users/users.service'; import { UsersService, RequestContext } from '../users/users.service';
@@ -14,6 +10,7 @@ import { ProfileDto } from './dto/profile.dto';
import { UserMapper } from '../users/mappers/user.mapper'; import { UserMapper } from '../users/mappers/user.mapper';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
import { AuthenticatedUser } from './interfaces/authenticated-user.interface'; import { AuthenticatedUser } from './interfaces/authenticated-user.interface';
import { AppException } from '../common/exceptions/app-exception';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -34,23 +31,23 @@ export class AuthService {
userAgent: context.userAgent, userAgent: context.userAgent,
metadata: { reason: 'invalid_credentials' }, metadata: { reason: 'invalid_credentials' },
}); });
throw new UnauthorizedException('Invalid credentials'); throw new AppException('INVALID_CREDENTIALS');
} }
if (!user.isActive) { if (!user.isActive) {
await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context); await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context);
throw new ForbiddenException('Account is inactive'); throw new AppException('ACCOUNT_INACTIVE');
} }
if (user.isBlocked) { if (user.isBlocked) {
await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context); await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context);
throw new ForbiddenException('Account is blocked'); throw new AppException('ACCOUNT_BLOCKED');
} }
const valid = await this.passwordService.compare(dto.password, user.password); const valid = await this.passwordService.compare(dto.password, user.password);
if (!valid) { if (!valid) {
await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context); await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context);
throw new UnauthorizedException('Invalid credentials'); throw new AppException('INVALID_CREDENTIALS');
} }
const tokens = await this.issueTokens(user); const tokens = await this.issueTokens(user);
@@ -72,20 +69,20 @@ export class AuthService {
try { try {
payload = this.tokenService.verifyRefreshToken(refreshToken); payload = this.tokenService.verifyRefreshToken(refreshToken);
} catch { } catch {
throw new UnauthorizedException('Invalid refresh token'); throw new AppException('INVALID_REFRESH_TOKEN');
} }
const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken); const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken);
if (!valid) { if (!valid) {
throw new UnauthorizedException('Invalid refresh token'); throw new AppException('INVALID_REFRESH_TOKEN');
} }
const user = await this.usersService.findById(payload.sub); const user = await this.usersService.findById(payload.sub);
if (!user.isActive) { if (!user.isActive) {
throw new ForbiddenException('Account is inactive'); throw new AppException('ACCOUNT_INACTIVE');
} }
if (user.isBlocked) { if (user.isBlocked) {
throw new ForbiddenException('Account is blocked'); throw new AppException('ACCOUNT_BLOCKED');
} }
const tokens = await this.issueTokens(user); const tokens = await this.issueTokens(user);

View File

@@ -2,11 +2,11 @@ import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Request } from 'express'; import { Request } from 'express';
import { API_KEY_HEADER } from '../../common/constants/app.constants'; import { API_KEY_HEADER } from '../../common/constants/app.constants';
import { AppException } from '../../common/exceptions/app-exception';
/** /**
* API key authentication for inquiry endpoints. * API key authentication for inquiry endpoints.
@@ -22,11 +22,11 @@ export class ApiKeyGuard implements CanActivate {
const expected = this.configService.get<string>('auth.apiKey'); const expected = this.configService.get<string>('auth.apiKey');
if (!expected) { if (!expected) {
throw new UnauthorizedException('API key authentication is not configured'); throw new AppException('API_KEY_NOT_CONFIGURED');
} }
if (!apiKey || apiKey !== expected) { if (!apiKey || apiKey !== expected) {
throw new UnauthorizedException('Invalid or missing API key'); throw new AppException('INVALID_API_KEY');
} }
return true; return true;

View File

@@ -1,10 +1,11 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Request } from 'express'; import { Request } from 'express';
import { ADMIN_ROLES } from '../../common/enums/role.enum'; import { ADMIN_ROLES } from '../../common/enums/role.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants'; import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { AppException } from '../../common/exceptions/app-exception';
/** /**
* Validates that the user may call a specific inquiry endpoint. * Validates that the user may call a specific inquiry endpoint.
@@ -28,17 +29,18 @@ export class InquiryAccessGuard implements CanActivate {
const user = request.user; const user = request.user;
if (!user) { if (!user) {
throw new ForbiddenException('Authentication required'); throw new AppException('AUTHENTICATION_REQUIRED');
} }
if (ADMIN_ROLES.includes(user.role)) { if (ADMIN_ROLES.includes(user.role)) {
return true; return true;
} }
const inquiryKey = requiredInquiry as string; // Temporarily disabled: allow every authenticated user/client to call every inquiry.
if (!user.allowedInquiries.includes(inquiryKey)) { // const inquiryKey = requiredInquiry as string;
throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`); // if (!user.allowedInquiries.includes(inquiryKey)) {
} // throw new ForbiddenException(`Access denied for inquiry: ${inquiryKey}`);
// }
return true; return true;
} }

View File

@@ -1,6 +1,7 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants'; import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
import { AppException } from '../../common/exceptions/app-exception';
/** /**
* Protects routes with Passport JWT access strategy. * Protects routes with Passport JWT access strategy.
@@ -14,7 +15,7 @@ export class JwtAuthGuard extends AuthGuard(JWT_ACCESS_STRATEGY) {
_context: ExecutionContext, _context: ExecutionContext,
): TUser { ): TUser {
if (err || !user) { if (err || !user) {
throw err ?? new UnauthorizedException('Unauthorized'); throw err ?? new AppException('UNAUTHORIZED');
} }
return user; return user;
} }

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Request } from 'express'; import { Request } from 'express';
import { ROLES_KEY } from '../constants/auth.constants'; import { ROLES_KEY } from '../constants/auth.constants';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { Role } from '../../common/enums/role.enum'; import { Role } from '../../common/enums/role.enum';
import { AppException } from '../../common/exceptions/app-exception';
/** /**
* Enforces @Roles() metadata against the authenticated user's role. * Enforces @Roles() metadata against the authenticated user's role.
@@ -26,7 +27,7 @@ export class RolesGuard implements CanActivate {
const user = request.user; const user = request.user;
if (!user || !requiredRoles.includes(user.role)) { if (!user || !requiredRoles.includes(user.role)) {
throw new ForbiddenException('Insufficient role permissions'); throw new AppException('INSUFFICIENT_ROLE');
} }
return true; return true;

View File

@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@@ -6,6 +6,7 @@ import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants';
import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { UsersService } from '../../users/users.service'; import { UsersService } from '../../users/users.service';
import { AppException } from '../../common/exceptions/app-exception';
/** /**
* Validates access JWT and attaches a slim user object to the request. * Validates access JWT and attaches a slim user object to the request.
@@ -27,10 +28,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY)
const user = await this.usersService.findById(payload.sub); const user = await this.usersService.findById(payload.sub);
if (!user.isActive) { if (!user.isActive) {
throw new UnauthorizedException('Account is inactive'); throw new AppException('ACCOUNT_INACTIVE');
} }
if (user.isBlocked) { if (user.isBlocked) {
throw new UnauthorizedException('Account is blocked'); throw new AppException('ACCOUNT_BLOCKED');
} }
return { return {

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose'; import { Model } from 'mongoose';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
@@ -8,6 +8,7 @@ import { PasswordService } from '../auth/services/password.service';
import { User, UserDocument } from '../users/schemas/user.schema'; import { User, UserDocument } from '../users/schemas/user.schema';
import { UserMapper } from '../users/mappers/user.mapper'; import { UserMapper } from '../users/mappers/user.mapper';
import { UserResponseDto } from '../users/dto/user-response.dto'; import { UserResponseDto } from '../users/dto/user-response.dto';
import { AppException } from '../common/exceptions/app-exception';
export interface CreateSuperAdminInput { export interface CreateSuperAdminInput {
fullName: string; fullName: string;
@@ -27,7 +28,7 @@ export class CreateSuperAdminService {
async create(input: CreateSuperAdminInput): Promise<UserResponseDto> { async create(input: CreateSuperAdminInput): Promise<UserResponseDto> {
if (input.password.length < 8) { if (input.password.length < 8) {
throw new ConflictException('Password must be at least 8 characters'); throw new AppException('PASSWORD_TOO_SHORT');
} }
const username = input.username.toLowerCase().trim(); const username = input.username.toLowerCase().trim();
@@ -35,7 +36,7 @@ export class CreateSuperAdminService {
const existing = await this.userModel.findOne({ $or: [{ username }, { email }] }); const existing = await this.userModel.findOne({ $or: [{ username }, { email }] });
if (existing) { if (existing) {
throw new ConflictException('Username or email already exists'); throw new AppException('USERNAME_OR_EMAIL_EXISTS');
} }
const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN }); const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN });

View File

@@ -0,0 +1,374 @@
import { HttpStatus } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export interface ErrorCatalogEntry {
status: HttpStatus;
message: string;
messageFa: string;
}
export const ERROR_CATALOG = {
PROVIDER_TIMEOUT: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider request timed out',
messageFa: 'درخواست به سرویس‌دهنده زمان‌بر شد',
},
PROVIDER_ERROR: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider request failed',
messageFa: 'خطا در سرویس‌دهنده',
},
PROVIDER_NETWORK_ERROR: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider network request failed',
messageFa: 'ارتباط با سرویس‌دهنده برقرار نشد',
},
PROVIDER_BAD_RESPONSE: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider returned an invalid response',
messageFa: 'پاسخ سرویس‌دهنده معتبر نیست',
},
PROVIDER_AUTH_FAILED: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider authentication failed',
messageFa: 'احراز هویت سرویس‌دهنده ناموفق بود',
},
PROVIDER_REJECTED: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider rejected the request',
messageFa: 'سرویس‌دهنده درخواست را رد کرد',
},
NO_PROVIDERS: {
status: HttpStatus.UNPROCESSABLE_ENTITY,
message: 'No enabled providers configured for this inquiry',
messageFa: 'سرویس‌دهنده‌ای برای این استعلام فعال نیست',
},
ALL_PROVIDERS_FAILED: {
status: HttpStatus.UNPROCESSABLE_ENTITY,
message: 'All providers failed',
messageFa: 'تمامی سرویس‌دهنده‌ها با خطا مواجه شدند',
},
UNKNOWN_ERROR: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Unknown provider error',
messageFa: 'خطای ناشناخته در سرویس‌دهنده',
},
UNSUPPORTED_INQUIRY: {
status: HttpStatus.UNPROCESSABLE_ENTITY,
message: 'Provider does not support this inquiry type',
messageFa: 'سرویس‌دهنده از این نوع استعلام پشتیبانی نمی‌کند',
},
API_ERROR: {
status: HttpStatus.BAD_GATEWAY,
message: 'Provider API request failed',
messageFa: 'خطا در فراخوانی API سرویس‌دهنده',
},
HTTP_ERROR: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'HTTP request failed',
messageFa: 'درخواست ناموفق بود',
},
INTERNAL_ERROR: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
messageFa: 'خطای داخلی سرور',
},
NETWORK_ERROR: {
status: HttpStatus.BAD_GATEWAY,
message: 'Network request failed',
messageFa: 'خطا در اتصال به شبکه',
},
INQUIRY_FAILED: {
status: HttpStatus.UNPROCESSABLE_ENTITY,
message: 'Inquiry failed',
messageFa: 'استعلام با خطا مواجه شد',
},
INQUIRY_NO_MATCH: {
status: HttpStatus.OK,
message: 'Inquiry returned no matching result',
messageFa: 'نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد',
},
RECORD_NOT_FOUND: {
status: HttpStatus.OK,
message: 'Record not found',
messageFa: 'رکوردی یافت نشد',
},
SAYAH_ERROR: {
status: HttpStatus.BAD_GATEWAY,
message: 'Sayah inquiry failed',
messageFa: 'خطا در سرویس سیاح',
},
SHEBA_MISMATCH: {
status: HttpStatus.OK,
message: 'Submitted sheba does not match inquiry result',
messageFa: 'اطلاعات شبای وارد شده با نتیجه استعلام مطابقت ندارد',
},
VALIDATION_ERROR: {
status: HttpStatus.BAD_REQUEST,
message: 'Request validation failed',
messageFa: 'اطلاعات وارد شده معتبر نیست',
},
INVALID_CREDENTIALS: {
status: HttpStatus.UNAUTHORIZED,
message: 'Invalid credentials',
messageFa: 'نام کاربری یا رمز عبور صحیح نیست',
},
ACCOUNT_INACTIVE: {
status: HttpStatus.FORBIDDEN,
message: 'Account is inactive',
messageFa: 'حساب کاربری غیرفعال است',
},
ACCOUNT_BLOCKED: {
status: HttpStatus.FORBIDDEN,
message: 'Account is blocked',
messageFa: 'حساب کاربری مسدود شده است',
},
INVALID_REFRESH_TOKEN: {
status: HttpStatus.UNAUTHORIZED,
message: 'Invalid refresh token',
messageFa: 'توکن نوسازی نامعتبر است',
},
API_KEY_NOT_CONFIGURED: {
status: HttpStatus.UNAUTHORIZED,
message: 'API key authentication is not configured',
messageFa: 'احراز هویت با کلید API پیکربندی نشده است',
},
INVALID_API_KEY: {
status: HttpStatus.UNAUTHORIZED,
message: 'Invalid or missing API key',
messageFa: 'کلید API نامعتبر است یا ارسال نشده است',
},
UNAUTHORIZED: {
status: HttpStatus.UNAUTHORIZED,
message: 'Unauthorized',
messageFa: 'احراز هویت انجام نشده است',
},
AUTHENTICATION_REQUIRED: {
status: HttpStatus.FORBIDDEN,
message: 'Authentication required',
messageFa: 'احراز هویت الزامی است',
},
INSUFFICIENT_ROLE: {
status: HttpStatus.FORBIDDEN,
message: 'Insufficient role permissions',
messageFa: 'سطح دسترسی کافی نیست',
},
INSUFFICIENT_USER_MANAGEMENT_PERMISSION: {
status: HttpStatus.FORBIDDEN,
message: 'Insufficient permissions to manage users',
messageFa: 'دسترسی کافی برای مدیریت کاربران وجود ندارد',
},
SUPER_ADMIN_ROLE_REQUIRED: {
status: HttpStatus.FORBIDDEN,
message: 'Only SUPER_ADMIN can assign SUPER_ADMIN role',
messageFa: 'فقط مدیر ارشد می‌تواند نقش مدیر ارشد را اختصاص دهد',
},
USER_NOT_FOUND: {
status: HttpStatus.NOT_FOUND,
message: 'User not found',
messageFa: 'کاربر یافت نشد',
},
USERNAME_OR_EMAIL_EXISTS: {
status: HttpStatus.CONFLICT,
message: 'Username or email already exists',
messageFa: 'نام کاربری یا ایمیل قبلا ثبت شده است',
},
EMAIL_ALREADY_IN_USE: {
status: HttpStatus.CONFLICT,
message: 'Email already in use',
messageFa: 'این ایمیل قبلا استفاده شده است',
},
PASSWORD_TOO_SHORT: {
status: HttpStatus.CONFLICT,
message: 'Password must be at least 8 characters',
messageFa: 'رمز عبور باید حداقل ۸ کاراکتر باشد',
},
CANNOT_BLOCK_SELF: {
status: HttpStatus.BAD_REQUEST,
message: 'Cannot block your own account',
messageFa: 'امکان مسدود کردن حساب کاربری خودتان وجود ندارد',
},
RATE_LIMIT_EXCEEDED: {
status: HttpStatus.TOO_MANY_REQUESTS,
message: 'User rate limit exceeded',
messageFa: 'تعداد درخواست‌های مجاز کاربر به پایان رسیده است',
},
SHAHKAR_NO_MATCH: {
status: HttpStatus.OK,
message: 'No match found between the provided national code and mobile number',
messageFa: 'تطبیقی بین کد ملی و شماره موبایل وارد شده یافت نشد',
},
CAR_POLICY_NOT_FOUND: {
status: HttpStatus.OK,
message: 'No policy record found',
messageFa: 'سوابق بیمه‌نامه خودرو یافت نشد',
},
REAL_ESTATE_NOT_FOUND: {
status: HttpStatus.OK,
message: 'No property ownership found for the provided national code and postal code',
messageFa: 'ملکی برای کد ملی و کد پستی وارد شده یافت نشد',
},
REQUEST_FAILED: {
status: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Request failed',
messageFa: 'درخواست ناموفق بود',
},
} as const satisfies Record<string, ErrorCatalogEntry>;
export type AppErrorCode = keyof typeof ERROR_CATALOG;
export function isAppErrorCode(code: string): code is AppErrorCode {
return code in ERROR_CATALOG;
}
export function getErrorStatus(code: AppErrorCode): HttpStatus {
return ERROR_CATALOG[code].status;
}
export function buildNormalizedError(
code: AppErrorCode,
overrides: Partial<Omit<NormalizedErrorDto, 'code'>> = {},
): NormalizedErrorDto {
const entry = ERROR_CATALOG[code];
return {
code,
message: overrides.message ?? entry.message,
messageFa: overrides.messageFa ?? entry.messageFa,
providerMessage: overrides.providerMessage,
providerCode: overrides.providerCode,
providerTrackingCode: overrides.providerTrackingCode,
details: overrides.details,
conflict: overrides.conflict,
};
}
/**
* Resolve a Persian error message for a given code.
*
* @param code The NormalizedErrorDto.code value
* @param params Optional interpolation params (e.g. { provider, inquiryType })
* @returns The Persian message or undefined when no translation exists
*/
export function resolveErrorMessageFa(
code: string,
params?: Record<string, string>,
): string | undefined {
const entry = ERROR_CATALOG[code as AppErrorCode];
if (!entry) {
return undefined;
}
void params;
return entry.messageFa;
}
/**
* Map well-known provider error messages / codes that originate
* from upstream providers (returned as providerMessage) to their
* Persian equivalents when the provider itself does not return a
* Persian message.
*/
export const PROVIDER_MESSAGE_TRANSLATIONS: Record<string, string> = {
'No match found between the provided national code and mobile number':
'تطبیقی بین کد ملی و شماره موبایل وارد شده یافت نشد',
'Shahkar inquiry failed': 'خطا در استعلام شاهکار',
'No policy record found': 'سوابق بیمه‌نامه خودرو یافت نشد',
'Record not found': 'رکوردی یافت نشد',
'Request failed': 'درخواست ناموفق بود',
'No property ownership found for the provided national code and postal code':
'ملکی برای کد ملی و کد پستی وارد شده یافت نشد',
'Inquiry returned no matching result': 'نتیجه‌ای مطابق با اطلاعات وارد شده یافت نشد',
'Insufficient role permissions': 'سطح دسترسی کافی نیست',
'Invalid credentials': 'نام کاربری یا رمز عبور صحیح نیست',
'Account is inactive': 'حساب کاربری غیرفعال است',
'Account is blocked': 'حساب کاربری مسدود شده است',
'Invalid refresh token': 'توکن نوسازی نامعتبر است',
};
export const PROVIDER_PUBLIC_MESSAGE_TRANSLATIONS: Record<
string,
{ message: string; messageFa: string }
> = {
'کد ملی اشتباه است': {
message: 'Invalid national code',
messageFa: 'کد ملی اشتباه است',
},
'کدملی اشتباه است': {
message: 'Invalid national code',
messageFa: 'کد ملی اشتباه است',
},
};
export const MESSAGE_CODE_MAP: Record<string, AppErrorCode> = {
'Invalid credentials': 'INVALID_CREDENTIALS',
'Account is inactive': 'ACCOUNT_INACTIVE',
'Account is blocked': 'ACCOUNT_BLOCKED',
'Invalid refresh token': 'INVALID_REFRESH_TOKEN',
'API key authentication is not configured': 'API_KEY_NOT_CONFIGURED',
'Invalid or missing API key': 'INVALID_API_KEY',
Unauthorized: 'UNAUTHORIZED',
'Authentication required': 'AUTHENTICATION_REQUIRED',
'Insufficient role permissions': 'INSUFFICIENT_ROLE',
'Insufficient permissions to manage users': 'INSUFFICIENT_USER_MANAGEMENT_PERMISSION',
'Only SUPER_ADMIN can assign SUPER_ADMIN role': 'SUPER_ADMIN_ROLE_REQUIRED',
'User not found': 'USER_NOT_FOUND',
'Username or email already exists': 'USERNAME_OR_EMAIL_EXISTS',
'Email already in use': 'EMAIL_ALREADY_IN_USE',
'Password must be at least 8 characters': 'PASSWORD_TOO_SHORT',
'Cannot block your own account': 'CANNOT_BLOCK_SELF',
'User rate limit exceeded': 'RATE_LIMIT_EXCEEDED',
};
export function resolveErrorCodeFromMessage(message: string): AppErrorCode | undefined {
return MESSAGE_CODE_MAP[message.trim()];
}
/**
* Try to translate an upstream provider message to Persian.
*
* @param providerMessage The English message returned by the provider
* @returns The Persian equivalent or undefined when no mapping exists
*/
export function translateProviderMessage(providerMessage: string): string | undefined {
return PROVIDER_MESSAGE_TRANSLATIONS[providerMessage.trim()];
}
export function resolveProviderPublicMessage(
providerMessage: string | undefined,
fallbackMessage = 'Provider request failed',
): { message: string; messageFa: string } {
const trimmed = providerMessage?.trim();
if (!trimmed) {
return {
message: fallbackMessage,
messageFa: ERROR_CATALOG.PROVIDER_ERROR.messageFa,
};
}
const direct = PROVIDER_PUBLIC_MESSAGE_TRANSLATIONS[trimmed];
if (direct) {
return direct;
}
const messageFa = translateProviderMessage(trimmed);
if (messageFa) {
return {
message: trimmed,
messageFa,
};
}
if (/[آ-ی]/.test(trimmed)) {
return {
message: fallbackMessage,
messageFa: trimmed,
};
}
return {
message: trimmed,
messageFa: ERROR_CATALOG.PROVIDER_ERROR.messageFa,
};
}

View File

@@ -18,11 +18,14 @@ export class BaseInquiryResponseDto<T = Record<string, unknown>> {
@ApiPropertyOptional({ example: 'Inquiry completed successfully' }) @ApiPropertyOptional({ example: 'Inquiry completed successfully' })
message?: string; message?: string;
@ApiPropertyOptional() @ApiPropertyOptional({ example: 'استعلام با موفقیت انجام شد' })
data?: T; messageFa?: string;
@ApiPropertyOptional({ type: NormalizedErrorDto }) @ApiPropertyOptional({ nullable: true })
error?: NormalizedErrorDto; data?: T | null;
@ApiPropertyOptional({ type: NormalizedErrorDto, nullable: true })
error?: NormalizedErrorDto | null;
@ApiProperty({ example: 342, description: 'Duration in milliseconds' }) @ApiProperty({ example: 342, description: 'Duration in milliseconds' })
duration!: number; duration!: number;

View File

@@ -11,9 +11,30 @@ export class NormalizedErrorDto {
@ApiProperty({ example: 'Provider request timed out' }) @ApiProperty({ example: 'Provider request timed out' })
message!: string; message!: string;
@ApiPropertyOptional({ example: 'درخواست به سرویسدهنده زمانبر شد' })
messageFa?: string;
@ApiPropertyOptional({ example: 'سرویس در دسترس نیست' }) @ApiPropertyOptional({ example: 'سرویس در دسترس نیست' })
providerMessage?: string; providerMessage?: string;
@ApiPropertyOptional({ example: '503' }) @ApiPropertyOptional({ example: '503' })
providerCode?: string; providerCode?: string;
@ApiPropertyOptional({
example: 'MjFiNDQwYjAtOTdkMy00YjFkLWEzN2UtMWEyOTk2NDE0MjRm',
description: 'Upstream provider reference code when available',
})
providerTrackingCode?: string;
@ApiPropertyOptional({
example: [{ field: 'sheba', constraints: ['sheba must start with IR and contain 24 digits'] }],
description: 'Field-level validation errors when code is VALIDATION_ERROR',
})
details?: Array<{ field: string; constraints: string[] }>;
@ApiPropertyOptional({
example: { nationalCode: '4311402422', NtnlId: '0015790231' },
description: 'Conflicting values when submitted data does not match provider result',
})
conflict?: Record<string, string>;
} }

View File

@@ -10,4 +10,7 @@ export enum InquiryType {
POSTAL_CODE = 'POSTAL_CODE_INQUIRY', POSTAL_CODE = 'POSTAL_CODE_INQUIRY',
LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY', LEGAL_PERSON = 'LEGAL_PERSON_INQUIRY',
CAR_PLATE = 'CAR_PLATE_INQUIRY', CAR_PLATE = 'CAR_PLATE_INQUIRY',
POLICY_BY_CHASSIS = 'POLICY_BY_CHASSIS_INQUIRY',
POLICY_BY_PLATE = 'POLICY_BY_PLATE_INQUIRY',
POLICY_BY_NATIONAL_CODE = 'POLICY_BY_NATIONAL_CODE_INQUIRY',
} }

View File

@@ -5,6 +5,7 @@
export enum ProviderName { export enum ProviderName {
HAMTA = 'HAMTA', HAMTA = 'HAMTA',
MOALLEM = 'MOALLEM', MOALLEM = 'MOALLEM',
PARSIAN = 'PARSIAN',
TEJARATNOU = 'TEJARATNOU', TEJARATNOU = 'TEJARATNOU',
AMITIS = 'AMITIS', AMITIS = 'AMITIS',
} }

View File

@@ -0,0 +1,94 @@
import { HttpStatus } from '@nestjs/common';
import { AppException } from './app-exception';
import {
buildNormalizedError,
resolveProviderPublicMessage,
resolveErrorCodeFromMessage,
} from '../constants/error-messages';
import { translateError } from '../helpers/translate-error.helper';
describe('application error contract', () => {
it('builds normalized errors from stable catalog codes', () => {
const error = buildNormalizedError('INVALID_CREDENTIALS');
expect(error).toEqual({
code: 'INVALID_CREDENTIALS',
message: 'Invalid credentials',
messageFa: 'نام کاربری یا رمز عبور صحیح نیست',
providerMessage: undefined,
providerCode: undefined,
providerTrackingCode: undefined,
details: undefined,
conflict: undefined,
});
});
it('serializes AppException with the catalog status and normalized error', () => {
const exception = new AppException('RATE_LIMIT_EXCEEDED');
expect(exception.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect(exception.normalizedError.code).toBe('RATE_LIMIT_EXCEEDED');
expect(exception.normalizedError.messageFa).toBe(
'تعداد درخواست‌های مجاز کاربر به پایان رسیده است',
);
});
it('bridges known legacy English messages to catalog codes', () => {
expect(resolveErrorCodeFromMessage('Invalid credentials')).toBe('INVALID_CREDENTIALS');
expect(
translateError({
code: 'HTTP_ERROR',
message: 'Invalid credentials',
}),
).toMatchObject({
code: 'INVALID_CREDENTIALS',
messageFa: 'نام کاربری یا رمز عبور صحیح نیست',
});
});
it('keeps raw provider diagnostics separate from public provider messages', () => {
expect(
translateError({
code: 'PROVIDER_ERROR',
message: 'Invalid national code',
messageFa: 'کد ملی اشتباه است',
providerMessage: 'کد ملی اشتباه است',
providerCode: 'PROVIDER_ERROR',
providerTrackingCode: 'provider-tracking',
}),
).toEqual({
code: 'PROVIDER_ERROR',
message: 'Invalid national code',
messageFa: 'کد ملی اشتباه است',
providerMessage: 'کد ملی اشتباه است',
providerCode: 'PROVIDER_ERROR',
providerTrackingCode: 'provider-tracking',
details: undefined,
conflict: undefined,
});
});
it('maps known Persian provider text to stable public English and Persian messages', () => {
expect(resolveProviderPublicMessage('کد ملی اشتباه است')).toEqual({
message: 'Invalid national code',
messageFa: 'کد ملی اشتباه است',
});
});
it('uses the generic public message for unknown provider errors', () => {
expect(
translateError({
code: 'UNKNOWN_ERROR',
message: 'Unknown provider error',
providerMessage: 'No policy record found',
providerCode: 'POLICY_NOT_FOUND',
}),
).toMatchObject({
code: 'UNKNOWN_ERROR',
message: 'Unknown provider error',
messageFa: 'خطای ناشناخته در سرویس‌دهنده',
providerMessage: 'No policy record found',
providerCode: 'POLICY_NOT_FOUND',
});
});
});

View File

@@ -0,0 +1,34 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import {
AppErrorCode,
buildNormalizedError,
getErrorStatus,
} from '../constants/error-messages';
export interface AppExceptionOptions {
message?: string;
messageFa?: string;
providerMessage?: string;
providerCode?: string;
providerTrackingCode?: string;
details?: NormalizedErrorDto['details'];
conflict?: NormalizedErrorDto['conflict'];
status?: HttpStatus;
}
export class AppException extends HttpException {
public readonly normalizedError: NormalizedErrorDto;
constructor(code: AppErrorCode, options: AppExceptionOptions = {}) {
const normalizedError = buildNormalizedError(code, options);
super(
{
message: normalizedError.message,
error: normalizedError,
},
options.status ?? getErrorStatus(code),
);
this.normalizedError = normalizedError;
}
}

View File

@@ -6,6 +6,7 @@ export class InquiryException extends HttpException {
message: string, message: string,
public readonly normalizedError: NormalizedErrorDto, public readonly normalizedError: NormalizedErrorDto,
status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY, status: HttpStatus = HttpStatus.UNPROCESSABLE_ENTITY,
public readonly provider?: string,
) { ) {
super({ message, error: normalizedError }, status); super({ message, error: normalizedError }, status);
} }

View File

@@ -7,9 +7,16 @@ import {
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { buildInquiryResponse } from '../helpers/inquiry-response.helper';
import { ProviderException } from '../exceptions/provider.exception'; import { ProviderException } from '../exceptions/provider.exception';
import { isNormalizedError } from '../helpers/validation-error.helper';
import { translateError } from '../helpers/translate-error.helper';
import { AppException } from '../exceptions/app-exception';
import {
buildNormalizedError,
resolveErrorCodeFromMessage,
} from '../constants/error-messages';
/** /**
* Global exception filter — normalizes all errors into BaseInquiryResponseDto * Global exception filter — normalizes all errors into BaseInquiryResponseDto
@@ -22,7 +29,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { trackingCode?: string }>(); const request = ctx.getRequest<Request & { requestId?: string; trackingCode?: string }>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException
@@ -31,47 +38,68 @@ export class AllExceptionsFilter implements ExceptionFilter {
const normalizedError = this.extractNormalizedError(exception); const normalizedError = this.extractNormalizedError(exception);
const trackingCode = request.trackingCode ?? 'UNKNOWN'; const trackingCode = request.trackingCode ?? 'UNKNOWN';
const requestId = request.requestId ?? (request.headers['x-request-id'] as string | undefined) ?? 'unknown';
this.logger.error( this.logger.error(
`requestId=${request.headers['x-request-id']} | trackingCode=${trackingCode} | ${normalizedError.message}`, `requestId=${requestId} | trackingCode=${trackingCode} | code=${normalizedError.code} | ${normalizedError.message}`,
exception instanceof Error ? exception.stack : undefined, exception instanceof Error ? exception.stack : undefined,
); );
const body: BaseInquiryResponseDto = { const body = buildInquiryResponse({
success: false, success: false,
provider: 'GATEWAY', provider: 'GATEWAY',
trackingCode, trackingCode,
message: normalizedError.message, message: normalizedError.message,
error: normalizedError,
duration: 0, duration: 0,
}; error: normalizedError,
});
response.status(status).json(body); response.status(status).json(body);
} }
private extractNormalizedError(exception: unknown): NormalizedErrorDto { private extractNormalizedError(exception: unknown): NormalizedErrorDto {
if (exception instanceof ProviderException) { if (exception instanceof AppException) {
return exception.normalizedError; return exception.normalizedError;
} }
if (exception instanceof ProviderException) {
return translateError(exception.normalizedError);
}
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
const res = exception.getResponse(); const res = exception.getResponse();
if (typeof res === 'object' && res !== null && 'error' in res) {
return (res as { error: NormalizedErrorDto }).error; if (typeof res === 'object' && res !== null) {
if ('error' in res && isNormalizedError((res as { error: unknown }).error)) {
return translateError((res as { error: NormalizedErrorDto }).error);
}
if (isNormalizedError(res)) {
return translateError(res);
}
} }
const message = const message =
typeof res === 'string' typeof res === 'string'
? res ? res
: (res as { message?: string | string[] }).message; : (res as { message?: string | string[] }).message;
return {
code: 'HTTP_ERROR', const normalizedMessage = Array.isArray(message)
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), ? message.join(', ')
}; : String(message ?? exception.message);
const code = resolveErrorCodeFromMessage(normalizedMessage);
return code
? buildNormalizedError(code, { message: normalizedMessage })
: translateError({
code: 'HTTP_ERROR',
message: normalizedMessage,
});
} }
return { return translateError({
code: 'INTERNAL_ERROR', code: 'INTERNAL_ERROR',
message: exception instanceof Error ? exception.message : 'Unexpected error', message: exception instanceof Error ? exception.message : 'Unexpected error',
}; });
} }
} }

View File

@@ -0,0 +1,72 @@
import { InquiryType } from '../enums/inquiry-type.enum';
export interface CentInsurApiResponse {
IsSucceed?: boolean;
Result?: {
Result?: boolean;
ErrorMessage?: string | null;
[key: string]: unknown;
};
TrackingCode?: string;
message?: string;
code?: string;
success?: boolean;
}
export interface CentInsurProviderError {
message: string;
code: string;
providerTrackingCode?: string;
}
function getNoMatchMessage(inquiryType?: InquiryType): string {
switch (inquiryType) {
case InquiryType.REAL_ESTATE:
return 'No property ownership found for the provided national code and postal code';
default:
return 'Inquiry returned no matching result';
}
}
export function getCentInsurProviderError(
body: CentInsurApiResponse,
inquiryType?: InquiryType,
): CentInsurProviderError | null {
if (!('IsSucceed' in body)) {
return null;
}
const providerTrackingCode = body.TrackingCode?.trim() || undefined;
if (body.IsSucceed === false) {
return {
message: body.Result?.ErrorMessage?.trim() || 'Request failed',
code: 'API_ERROR',
providerTrackingCode,
};
}
if (body.Result?.Result === false) {
return {
message: body.Result?.ErrorMessage?.trim() || getNoMatchMessage(inquiryType),
code: 'INQUIRY_NO_MATCH',
providerTrackingCode,
};
}
return null;
}
export function describeCentInsurResponse(body: CentInsurApiResponse): string {
const innerResult =
body.Result && 'Result' in body.Result ? String(body.Result.Result) : 'undefined';
return [
`IsSucceed=${String(body.IsSucceed)}`,
`Result.Result=${innerResult}`,
`ErrorMessage=${body.Result?.ErrorMessage ?? 'none'}`,
body.TrackingCode ? `TrackingCode=${body.TrackingCode}` : null,
]
.filter(Boolean)
.join(' | ');
}

View File

@@ -0,0 +1,222 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
import { Logger } from '@nestjs/common';
import type { Agent as HttpAgent } from 'http';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { SocksProxyAgent } = require('socks-proxy-agent');
const outboundHttpLogger = new Logger('OutboundHTTP');
let cachedSocksAgents: { httpAgent: HttpAgent; httpsAgent: HttpAgent } | undefined;
let outboundHttpDebugInstalled = false;
function getOutboundProxyUrl(): string | undefined {
const value = process.env.OUTBOUND_PROXY?.trim();
return value || undefined;
}
export function isOutboundHttpDebugEnabled(): boolean {
return process.env.OUTBOUND_HTTP_DEBUG === 'true';
}
function isSocksProxy(proxyUrl: string): boolean {
return /^socks/i.test(proxyUrl);
}
function getSocksAgents(proxyUrl: string): { httpAgent: HttpAgent; httpsAgent: HttpAgent } {
if (!cachedSocksAgents) {
const agent = new SocksProxyAgent(proxyUrl);
cachedSocksAgents = { httpAgent: agent, httpsAgent: agent };
}
return cachedSocksAgents;
}
function truncate(value: string, max = 800): string {
return value.length <= max ? value : `${value.slice(0, max)}...(truncated)`;
}
function sanitizeForLog(data: unknown): string {
if (data === undefined || data === null) {
return String(data);
}
if (typeof data === 'string') {
return truncate(
data
.replace(/(<(?:\w+:)?Password[^>]*>)[^<]*(<\/(?:\w+:)?Password>)/gi, '$1***$2')
.replace(/password=[^&]*/gi, 'password=***')
.replace(/("password"\s*:\s*")[^"]*"/gi, '$1***"'),
);
}
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) {
return truncate(data.toString('utf8').replace(/password=[^&\r\n]*/gi, 'password=***'));
}
try {
const serialized = JSON.stringify(data, (key, value) => {
if (/password|secret|authorization|token|refresh/i.test(key)) {
return '***';
}
return value;
});
return truncate(serialized);
} catch {
return truncate(String(data));
}
}
function resolveRequestUrl(config: InternalAxiosRequestConfig): string {
const url = config.url ?? '';
if (/^https?:\/\//i.test(url)) {
return url;
}
const base = config.baseURL ?? '';
if (!base) {
return url;
}
return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`;
}
function logOutboundRequest(config: InternalAxiosRequestConfig): void {
const method = (config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(config);
outboundHttpLogger.log(`${method} ${url}`);
if (config.params) {
outboundHttpLogger.log(` query=${sanitizeForLog(config.params)}`);
}
if (config.data !== undefined) {
outboundHttpLogger.log(` body=${sanitizeForLog(config.data)}`);
}
if (config.headers?.SOAPAction) {
outboundHttpLogger.log(` SOAPAction=${String(config.headers.SOAPAction)}`);
}
}
function logOutboundResponse(response: {
config: InternalAxiosRequestConfig;
status: number;
data: unknown;
}): void {
const start = (response.config as InternalAxiosRequestConfig & { _outboundStart?: number })
._outboundStart;
const durationMs = start ? Date.now() - start : undefined;
const method = (response.config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(response.config);
outboundHttpLogger.log(
`${response.status} ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''}`,
);
outboundHttpLogger.log(` response=${sanitizeForLog(response.data)}`);
}
function logOutboundError(error: AxiosError): void {
const config = error.config;
if (!config) {
outboundHttpLogger.error(`← FAILED | ${error.message}`);
return;
}
const start = (config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart;
const durationMs = start ? Date.now() - start : undefined;
const method = (config.method ?? 'GET').toUpperCase();
const url = resolveRequestUrl(config);
outboundHttpLogger.error(
`← FAILED ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''} | status=${error.response?.status ?? 'N/A'} | ${error.message}`,
);
if (error.response?.data !== undefined) {
outboundHttpLogger.error(` response=${sanitizeForLog(error.response.data)}`);
}
}
export function installOutboundHttpDebugInterceptors(): void {
if (!isOutboundHttpDebugEnabled() || outboundHttpDebugInstalled) {
return;
}
outboundHttpDebugInstalled = true;
outboundHttpLogger.log('Outbound HTTP debug logging enabled (OUTBOUND_HTTP_DEBUG=true)');
axios.interceptors.request.use((config) => {
(config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart =
Date.now();
logOutboundRequest(config);
return config;
});
axios.interceptors.response.use(
(response) => {
logOutboundResponse(response);
return response;
},
(error: AxiosError) => {
logOutboundError(error);
return Promise.reject(error);
},
);
}
installOutboundHttpDebugInterceptors();
/**
* Axios defaults for outbound provider calls.
* Set OUTBOUND_PROXY to route traffic through an SSH tunnel (e.g. Termius SOCKS on 6565).
*/
export function getOutboundAxiosDefaults(): AxiosRequestConfig {
const proxyUrl = getOutboundProxyUrl();
if (!proxyUrl) {
return {};
}
if (isSocksProxy(proxyUrl)) {
const agents = getSocksAgents(proxyUrl);
return {
httpAgent: agents.httpAgent,
httpsAgent: agents.httpsAgent,
proxy: false,
};
}
const parsed = new URL(proxyUrl);
const defaultPort = parsed.protocol === 'https:' ? 443 : 80;
return {
proxy: {
protocol: parsed.protocol.replace(':', ''),
host: parsed.hostname,
port: Number(parsed.port || defaultPort),
},
};
}
export function mergeOutboundAxiosConfig(config: AxiosRequestConfig = {}): AxiosRequestConfig {
const outbound = getOutboundAxiosDefaults();
return {
...outbound,
...config,
httpAgent: config.httpAgent ?? outbound.httpAgent,
httpsAgent: config.httpsAgent ?? outbound.httpsAgent,
};
}
export function createOutboundAxiosInstance(config: CreateAxiosDefaults = {}): AxiosInstance {
const outbound = getOutboundAxiosDefaults();
return axios.create({
...config,
httpAgent: config.httpAgent ?? outbound.httpAgent,
httpsAgent: config.httpsAgent ?? outbound.httpsAgent,
proxy: config.proxy ?? outbound.proxy,
});
}

View File

@@ -0,0 +1,36 @@
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { translateError } from './translate-error.helper';
export function buildInquiryResponse<T = Record<string, unknown>>(params: {
success: boolean;
provider: string;
trackingCode: string;
message: string;
duration: number;
data?: T | null;
error?: NormalizedErrorDto | null;
}): BaseInquiryResponseDto<T> {
const error = params.success ? null : (params.error != null ? translateError(params.error) : null);
return {
success: params.success,
provider: params.provider,
trackingCode: params.trackingCode,
message: params.success ? params.message : undefined,
messageFa: undefined,
duration: params.duration,
data: params.success ? (params.data ?? null) : null,
error,
};
}
export function normalizeInquiryResponse<T = Record<string, unknown>>(
response: BaseInquiryResponseDto<T>,
): BaseInquiryResponseDto<T> {
return {
...response,
data: response.success ? (response.data ?? null) : null,
error: response.success ? null : (response.error ?? null),
};
}

View File

@@ -1,4 +1,5 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
export interface RequestLogContext { export interface RequestLogContext {
requestId: string; requestId: string;
@@ -29,10 +30,12 @@ export class RequestLogger {
} }
logFailure(ctx: RequestLogContext, message: string, error?: unknown): void { logFailure(ctx: RequestLogContext, message: string, error?: unknown): void {
this.logger.error( const errorDetail = formatErrorDetail(error);
this.format({ ...ctx, success: false }, message), const line = errorDetail
error instanceof Error ? error.stack : undefined, ? `${this.format({ ...ctx, success: false }, message)} | error=${errorDetail}`
); : this.format({ ...ctx, success: false }, message);
this.logger.error(line, error instanceof Error ? error.stack : undefined);
} }
private format(ctx: RequestLogContext, message: string): string { private format(ctx: RequestLogContext, message: string): string {
@@ -49,3 +52,21 @@ export class RequestLogger {
return parts.join(' | '); return parts.join(' | ');
} }
} }
function formatErrorDetail(error: unknown): string | undefined {
if (!(error instanceof Error)) {
return error !== undefined ? String(error) : undefined;
}
const normalized = (error as Error & { normalizedError?: NormalizedErrorDto }).normalizedError;
if (normalized?.providerMessage) {
const code = normalized.providerCode ? ` (${normalized.providerCode})` : '';
return `${normalized.providerMessage}${code}`;
}
if (normalized?.message && normalized.message !== error.message) {
return normalized.message;
}
return error.message;
}

View File

@@ -0,0 +1,70 @@
export interface SayahApiResponse {
ReturnValue?: boolean;
HasError?: boolean;
IsSucceed?: boolean;
isSucceed?: boolean;
Errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
Result?: {
ErrorMessage?: string | null;
[key: string]: unknown;
};
}
export function formatSayahErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): string {
if (!errors) {
return 'Request failed';
}
if (Array.isArray(errors)) {
if (errors.length === 0) {
return 'Request failed';
}
return errors.map((error) => `${error.Code}: ${error.Message}`).join(', ');
}
const entries = Object.entries(errors);
if (entries.length === 0) {
return 'Request failed';
}
return entries.map(([code, field]) => `${field} (code: ${code})`).join(', ');
}
export function getSayahProviderError(
body: SayahApiResponse,
): { message: string; code: string } | null {
if ('ReturnValue' in body || 'HasError' in body) {
if (body.HasError) {
return {
message: formatSayahErrors(body.Errors),
code: 'SAYAH_ERROR',
};
}
if (body.ReturnValue === false) {
return {
message: formatSayahErrors(body.Errors),
code: 'SHEBA_MISMATCH',
};
}
return null;
}
const isSucceed = body.IsSucceed ?? body.isSucceed;
if (isSucceed === false) {
const errors = body.Errors ?? body.errors;
return {
message:
(Array.isArray(errors) || (errors && Object.keys(errors).length > 0)
? formatSayahErrors(errors)
: body.Result?.ErrorMessage) ?? 'Request failed',
code: 'API_ERROR',
};
}
return null;
}

View File

@@ -0,0 +1,124 @@
function decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function normalizeSoapTextValue(value: string): string {
if (/i:nil\s*=\s*["']true["']/i.test(value)) {
return '';
}
const nestedStrings = [
...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi),
]
.map((match) => decodeXml(match[1].trim()))
.filter(Boolean);
if (nestedStrings.length > 0) {
return nestedStrings.join(', ');
}
return decodeXml(value.trim());
}
export interface ShahkarInquiryFields {
response?: string;
result?: string;
comment?: string;
requestId?: string;
id?: string;
ErrorNams?: string;
}
export function parseShahkarInqueryResult(soapXml: string): ShahkarInquiryFields {
const blockMatch = soapXml.match(
/<(?:[\w]+:)?ShahkarInqueryResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?ShahkarInqueryResult>/i,
);
if (!blockMatch) {
return {};
}
const fields: Record<string, string> = {};
const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(blockMatch[1])) !== null) {
fields[match[1]] = normalizeSoapTextValue(match[2]);
}
return fields;
}
export function normalizeShahkarFields(raw: Record<string, unknown>): ShahkarInquiryFields {
const readString = (value: unknown): string | undefined => {
if (value === null || value === undefined) {
return undefined;
}
const text = String(value).trim();
return text.length > 0 ? text : undefined;
};
return {
response: readString(raw.response ?? raw.Response),
result: readString(raw.result ?? raw.Result),
comment: readString(raw.comment ?? raw.Comment),
requestId: readString(raw.requestId ?? raw.RequestId),
id: readString(raw.id ?? raw.Id),
ErrorNams: readString(raw.ErrorNams ?? raw.errorNams),
};
}
export function isShahkarSuccess(fields: ShahkarInquiryFields): boolean {
const responseCode = fields.response?.trim();
const resultText = fields.result?.trim().toUpperCase() ?? '';
return responseCode === '200' && resultText.startsWith('OK');
}
export function getShahkarProviderError(
fields: ShahkarInquiryFields,
): { message: string; code: string; providerCode?: string; providerTrackingCode?: string } | null {
if (isShahkarSuccess(fields)) {
return null;
}
const comment = fields.comment?.trim();
const errorNams = fields.ErrorNams?.trim();
const resultText = fields.result?.trim();
const responseCode = fields.response?.trim();
const providerTrackingCode = fields.requestId?.trim() || undefined;
if (resultText === 'NotIdentifiedException' || responseCode === '600') {
return {
message:
comment ||
'No match found between the provided national code and mobile number',
code: 'INQUIRY_NO_MATCH',
providerCode: resultText ?? responseCode,
providerTrackingCode,
};
}
return {
message: comment || errorNams || resultText || 'Shahkar inquiry failed',
code: 'PROVIDER_ERROR',
providerCode: resultText ?? responseCode,
providerTrackingCode,
};
}
export function describeShahkarResponse(fields: ShahkarInquiryFields): string {
return [
fields.response ? `response=${fields.response}` : null,
fields.result ? `result=${fields.result}` : null,
fields.comment ? `comment=${fields.comment}` : null,
fields.requestId ? `requestId=${fields.requestId}` : null,
]
.filter(Boolean)
.join(' | ');
}

View File

@@ -0,0 +1,85 @@
function decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function normalizeSoapTextValue(value: string): string {
if (/i:nil\s*=\s*["']true["']/i.test(value)) {
return '';
}
const nestedStrings = [
...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi),
]
.map((match) => decodeXml(match[1].trim()))
.filter(Boolean);
if (nestedStrings.length > 0) {
return nestedStrings.join(', ');
}
return decodeXml(value.trim());
}
function parseSoapFields(block: string): Record<string, string> {
const fields: Record<string, string> = {};
const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(block)) !== null) {
fields[match[1]] = normalizeSoapTextValue(match[2]);
}
return fields;
}
export function parseFirstCarPolicy(soapXml: string): Record<string, string> {
const resultMatch = soapXml.match(
/<(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result[^>]*>([\s\S]*?)<\/(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result>/i,
);
if (!resultMatch) {
return {};
}
const firstPolicyMatch = resultMatch[1].match(
/<(?:[\w]+:)?Policy(?:\s[^>]*)?>\s*<(?:[\w]+:)?Policy(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w]+:)?Policy>/i,
);
if (!firstPolicyMatch) {
return {};
}
return parseSoapFields(firstPolicyMatch[1]);
}
export function getCarPolicyProviderError(
soapXml: string,
policy: Record<string, string>,
): { message: string; code: string } | null {
const resultMatch = soapXml.match(
/<(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result[^>]*>([\s\S]*?)<\/(?:[\w]+:)?(?:CIIWSPolicyChassis|CIIWSPolicyVehicleMeli|CIIWSPolicyNationalId)Result>/i,
);
if (resultMatch) {
const errorMatch = resultMatch[1].match(
/<(?:[\w]+:)?Error(?![^>]*i:nil\s*=\s*["']true["'])[^>]*>([\s\S]*?)<\/(?:[\w]+:)?Error>/i,
);
if (errorMatch) {
const errorText = normalizeSoapTextValue(errorMatch[1]).trim();
if (errorText) {
return { message: errorText, code: 'PROVIDER_ERROR' };
}
}
}
if (Object.keys(policy).length === 0) {
return { message: 'No policy record found', code: 'RECORD_NOT_FOUND' };
}
return null;
}

View File

@@ -0,0 +1,92 @@
function decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
function normalizeSoapTextValue(value: string): string {
const nestedStrings = [
...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi),
]
.map((match) => decodeXml(match[1].trim()))
.filter(Boolean);
if (nestedStrings.length > 0) {
return nestedStrings.join(', ');
}
return decodeXml(value.trim());
}
export function parseCiiEstelamResult(soapXml: string): Record<string, string> {
const civilBlockMatch =
soapXml.match(
/<(?:[\w]+:)?CiiEstelamResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?CiiEstelamResult>/i,
) ??
soapXml.match(
/<(?:[\w]+:)?SubmitInqDteStsWithPstCodResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?SubmitInqDteStsWithPstCodResult>/i,
);
if (!civilBlockMatch) {
return {};
}
const fields: Record<string, string> = {};
const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi;
let match: RegExpExecArray | null;
while ((match = tagRegex.exec(civilBlockMatch[1])) !== null) {
fields[match[1]] = normalizeSoapTextValue(match[2]);
}
const errorNamsMatch = soapXml.match(
/<(?:[\w]+:)?ErrorNams(?![^>]*i:nil="true")[^>]*>([\s\S]*?)<\/(?:[\w]+:)?ErrorNams>/i,
);
if (errorNamsMatch) {
const errorNams = normalizeSoapTextValue(errorNamsMatch[1]);
if (errorNams) {
fields.ErrorNams = errorNams;
}
}
return fields;
}
export function getCivilRegistrationProviderError(
fields: Record<string, string>,
): { message: string; code: string } | null {
const message = fields.Message?.trim();
const exceptionMessage = fields.ExceptionMessage?.trim();
const errorNams = fields.ErrorNams?.trim();
const nin = fields.Nin?.trim();
const hasIdentity = Boolean(fields.Name?.trim() || fields.Family?.trim());
if (exceptionMessage) {
return { message: exceptionMessage, code: 'PROVIDER_ERROR' };
}
if (errorNams) {
return { message: errorNams, code: 'PROVIDER_ERROR' };
}
if (message && message.includes('err.')) {
return { message, code: 'RECORD_NOT_FOUND' };
}
if (!nin || nin === '0' || !hasIdentity) {
return {
message: message || 'Record not found',
code: 'RECORD_NOT_FOUND',
};
}
return null;
}
export function buildCivilRegistrationFullName(fields: Record<string, string>): string | undefined {
const fullName = [fields.Name, fields.Family].filter(Boolean).join(' ').trim();
return fullName || undefined;
}

View File

@@ -0,0 +1,60 @@
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import {
buildNormalizedError,
isAppErrorCode,
resolveErrorMessageFa,
resolveErrorCodeFromMessage,
} from '../constants/error-messages';
/**
* Ensures a NormalizedErrorDto has its `messageFa` field populated.
*
* Resolution order:
* 1. If `messageFa` is already set, return as-is.
* 2. If a broad gateway error carries a known legacy message, map it to that catalog code.
* 3. Use the gateway-owned code-based translation map.
* 4. If nothing matches, return unchanged.
*/
export function translateError(error: NormalizedErrorDto): NormalizedErrorDto {
if (error.messageFa) {
return error;
}
// Build interpolation context from the error
const params: Record<string, string> = {};
if (error.providerCode) params.providerCode = error.providerCode;
if (error.providerTrackingCode) params.providerTrackingCode = error.providerTrackingCode;
if (isAppErrorCode(error.code)) {
if (error.code === 'HTTP_ERROR' || error.code === 'INTERNAL_ERROR') {
const messageCode = resolveErrorCodeFromMessage(error.message);
if (messageCode) {
return buildNormalizedError(messageCode, {
message: error.message,
messageFa: error.messageFa,
providerMessage: error.providerMessage,
providerCode: error.providerCode,
providerTrackingCode: error.providerTrackingCode,
details: error.details,
conflict: error.conflict,
});
}
}
return buildNormalizedError(error.code, {
message: error.message,
messageFa: error.messageFa,
providerMessage: error.providerMessage,
providerCode: error.providerCode,
providerTrackingCode: error.providerTrackingCode,
details: error.details,
conflict: error.conflict,
});
}
const messageFa = resolveErrorMessageFa(error.code, params);
if (messageFa) return { ...error, messageFa };
// Last resort: return unchanged
return error;
}

View File

@@ -0,0 +1,213 @@
import { ValidationError } from 'class-validator';
import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { buildNormalizedError } from '../constants/error-messages';
export interface ValidationFieldError {
field: string;
constraints: string[];
}
const FIELD_LABELS_FA: Record<string, string> = {
nationalCode: 'کد ملی',
postalCode: 'کد پستی',
birthDate: 'تاریخ تولد',
mobileNo: 'شماره موبایل',
sheba: 'شماره شبا',
chassisNo: 'شماره شاسی',
plk1: 'بخش اول پلاک',
plk2: 'حرف پلاک',
plk3: 'بخش سوم پلاک',
plksrl: 'سری پلاک',
email: 'ایمیل',
password: 'رمز عبور',
newPassword: 'رمز عبور جدید',
role: 'نقش کاربر',
clientType: 'نوع کاربر',
username: 'نام کاربری',
fullName: 'نام کامل',
};
const CONSTRAINT_PRIORITY = [
'isNotEmpty',
'matches',
'isLength',
'length',
'minLength',
'maxLength',
'isEmail',
'isEnum',
'isIn',
'isString',
'isInt',
'isBoolean',
'isArray',
];
function pickConstraint(constraints: Record<string, string>): string {
for (const key of CONSTRAINT_PRIORITY) {
if (constraints[key]) {
return constraints[key];
}
}
return Object.values(constraints)[0] ?? 'Invalid value';
}
function fieldLabelFa(field: string): string {
return FIELD_LABELS_FA[field] ?? field;
}
function translateValidationConstraint(field: string, constraint: string): string {
const label = fieldLabelFa(field);
if (constraint.includes('must be exactly 10 digits')) {
return `${label} باید دقیقا ۱۰ رقم باشد`;
}
if (constraint.includes('birthDate must be in YYYY-MM-DD format')) {
return 'تاریخ تولد باید با فرمت YYYY-MM-DD باشد';
}
if (constraint.includes('birthDate must be in YYYY-MM-DD or YYYYMMDD format')) {
return 'تاریخ تولد باید با فرمت YYYY-MM-DD یا YYYYMMDD باشد';
}
if (constraint.includes('mobileNo must be an Iranian mobile number')) {
return 'شماره موبایل باید معتبر و با 09 شروع شود';
}
if (constraint.includes('sheba must start with IR and contain 24 digits')) {
return 'شماره شبا باید با IR شروع شود و ۲۴ رقم داشته باشد';
}
const exactDigits = constraint.match(/must be exactly (\d+) digits/);
if (exactDigits?.[1]) {
return `${label} باید دقیقا ${exactDigits[1]} رقم باشد`;
}
const minLength = constraint.match(/must be longer than or equal to (\d+) characters/);
if (minLength?.[1]) {
return `${label} باید حداقل ${minLength[1]} کاراکتر باشد`;
}
if (constraint.includes('must be an email')) {
return 'ایمیل باید معتبر باشد';
}
if (constraint.includes('must be one of the following values')) {
return `${label} مقدار معتبری ندارد`;
}
if (constraint.includes('should not be empty')) {
return `${label} الزامی است`;
}
if (constraint.includes('must be a string')) {
return `${label} باید متن باشد`;
}
if (constraint.includes('must be an integer number')) {
return `${label} باید عدد صحیح باشد`;
}
if (constraint.includes('must be a boolean value')) {
return `${label} باید مقدار درست یا نادرست باشد`;
}
if (constraint.includes('must be an array')) {
return `${label} باید آرایه باشد`;
}
return `${label} معتبر نیست`;
}
export function flattenValidationErrors(
errors: ValidationError[],
parentPath = '',
): ValidationFieldError[] {
const result: ValidationFieldError[] = [];
for (const error of errors) {
const field = parentPath ? `${parentPath}.${error.property}` : error.property;
if (error.constraints) {
result.push({
field,
constraints: [pickConstraint(error.constraints)],
});
}
if (error.children?.length) {
result.push(...flattenValidationErrors(error.children, field));
}
}
return result;
}
export function buildValidationErrorResponse(errors: ValidationError[]): {
normalizedError: NormalizedErrorDto;
details: ValidationFieldError[];
} {
const details = flattenValidationErrors(errors).slice(0, 1);
const selected = details[0];
const summary = selected?.constraints[0] ?? 'Request validation failed';
const messageFa = selected
? translateValidationConstraint(selected.field, selected.constraints[0] ?? '')
: 'اطلاعات ورودی نامعتبر است';
return {
normalizedError: buildNormalizedError('VALIDATION_ERROR', {
message: summary,
messageFa,
details,
}),
details,
};
}
export function formatHttpExceptionMessage(exception: unknown): string {
if (!(exception && typeof exception === 'object' && 'getResponse' in exception)) {
return exception instanceof Error ? exception.message : String(exception);
}
const response = (exception as { getResponse: () => unknown }).getResponse();
if (typeof response === 'string') {
return response;
}
if (typeof response !== 'object' || response === null) {
return exception instanceof Error ? exception.message : 'Request failed';
}
const body = response as {
message?: string | string[];
error?: unknown;
};
if (isNormalizedError(body.error)) {
return body.error.message;
}
if (Array.isArray(body.message)) {
return body.message.join('; ');
}
if (typeof body.message === 'string' && body.message.length > 0) {
return body.message;
}
return exception instanceof Error ? exception.message : 'Request failed';
}
export function isNormalizedError(value: unknown): value is NormalizedErrorDto {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
'message' in value &&
typeof (value as NormalizedErrorDto).code === 'string' &&
typeof (value as NormalizedErrorDto).message === 'string'
);
}

View File

@@ -5,7 +5,9 @@ import {
NestInterceptor, NestInterceptor,
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable, tap } from 'rxjs'; import { Observable, tap } from 'rxjs';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { RequestLogger } from '../helpers/request-logger.helper'; import { RequestLogger } from '../helpers/request-logger.helper';
import { formatHttpExceptionMessage } from '../helpers/validation-error.helper';
/** /**
* Logs HTTP request duration at the controller boundary. * Logs HTTP request duration at the controller boundary.
@@ -24,20 +26,40 @@ export class LoggingInterceptor implements NestInterceptor {
return next.handle().pipe( return next.handle().pipe(
tap({ tap({
next: () => { next: (body: unknown) => {
this.requestLogger.logSuccess( const durationMs = Date.now() - start;
{ requestId: req.requestId ?? 'unknown' }, const requestId = req.requestId ?? 'unknown';
`${req.method} ${req.url} completed in ${Date.now() - start}ms`, const summary = `${req.method} ${req.url} completed in ${durationMs}ms`;
);
if (this.isBusinessFailure(body)) {
const response = body as BaseInquiryResponseDto;
this.requestLogger.logFailure(
{ requestId, durationMs },
`${summary} | businessSuccess=false | ${response.message ?? response.error?.code ?? 'failed'}`,
);
return;
}
this.requestLogger.logSuccess({ requestId }, summary);
}, },
error: (err: unknown) => { error: (err: unknown) => {
const detail = formatHttpExceptionMessage(err);
this.requestLogger.logFailure( this.requestLogger.logFailure(
{ requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start }, { requestId: req.requestId ?? 'unknown', durationMs: Date.now() - start },
`${req.method} ${req.url} failed`, `${req.method} ${req.url} failed: ${detail}`,
err, err,
); );
}, },
}), }),
); );
} }
private isBusinessFailure(body: unknown): body is BaseInquiryResponseDto {
return Boolean(
body &&
typeof body === 'object' &&
'success' in body &&
(body as BaseInquiryResponseDto).success === false,
);
}
} }

View File

@@ -7,6 +7,7 @@ import {
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto'; import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
import { normalizeInquiryResponse } from '../helpers/inquiry-response.helper';
/** /**
* Ensures inquiry endpoints always emit BaseInquiryResponseDto shape. * Ensures inquiry endpoints always emit BaseInquiryResponseDto shape.
@@ -15,7 +16,7 @@ import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto';
export class ResponseTransformInterceptor implements NestInterceptor { export class ResponseTransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe( return next.handle().pipe(
map((data: BaseInquiryResponseDto) => data), map((data: BaseInquiryResponseDto) => normalizeInquiryResponse(data)),
); );
} }
} }

View File

@@ -7,6 +7,7 @@ export interface InquiryConfig {
url: string; url: string;
username: string; username: string;
password: string; password: string;
apiKey: string;
authMethod: AuthMethod; authMethod: AuthMethod;
} }
@@ -79,6 +80,7 @@ export default () => ({
} satisfies AmitisAuthServiceConfig, } satisfies AmitisAuthServiceConfig,
hamta: buildProviderConfig('HAMTA'), hamta: buildProviderConfig('HAMTA'),
moallem: buildProviderConfig('MOALLEM'), moallem: buildProviderConfig('MOALLEM'),
parsian: buildProviderConfig('PARSIAN'),
tejaratnou: { tejaratnou: {
enabled: process.env.TEJARATNOU_ENABLED !== 'false', enabled: process.env.TEJARATNOU_ENABLED !== 'false',
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),
@@ -93,14 +95,17 @@ export default () => ({
}, },
} satisfies TejaratNouConfig, } satisfies TejaratNouConfig,
inquiryRouting: { inquiryRouting: {
[InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.TEJARATNOU, []), [InquiryType.PERSON]: buildInquiryRoutingConfig('PERSON', ProviderName.PARSIAN, [
ProviderName.HAMTA,
ProviderName.TEJARATNOU,
]),
[InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [ [InquiryType.REAL_ESTATE]: buildInquiryRoutingConfig('REAL_ESTATE', ProviderName.HAMTA, [
ProviderName.MOALLEM, ProviderName.MOALLEM,
]), ]),
[InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.MOALLEM, [ [InquiryType.SHEBA]: buildInquiryRoutingConfig('SHEBA', ProviderName.PARSIAN, [
ProviderName.HAMTA, ProviderName.HAMTA,
]), ]),
[InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.MOALLEM, [ [InquiryType.SHAHKAR]: buildInquiryRoutingConfig('SHAHKAR', ProviderName.PARSIAN, [
ProviderName.HAMTA, ProviderName.HAMTA,
]), ]),
[InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [ [InquiryType.POSTAL_CODE]: buildInquiryRoutingConfig('POSTAL_CODE', ProviderName.MOALLEM, [
@@ -110,6 +115,21 @@ export default () => ({
ProviderName.MOALLEM, ProviderName.MOALLEM,
]), ]),
[InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []), [InquiryType.CAR_PLATE]: buildInquiryRoutingConfig('CAR_PLATE', ProviderName.HAMTA, []),
[InquiryType.POLICY_BY_CHASSIS]: buildInquiryRoutingConfig(
'POLICY_BY_CHASSIS',
ProviderName.PARSIAN,
[],
),
[InquiryType.POLICY_BY_PLATE]: buildInquiryRoutingConfig(
'POLICY_BY_PLATE',
ProviderName.PARSIAN,
[],
),
[InquiryType.POLICY_BY_NATIONAL_CODE]: buildInquiryRoutingConfig(
'POLICY_BY_NATIONAL_CODE',
ProviderName.PARSIAN,
[],
),
} satisfies Record<InquiryType, InquiryRoutingConfig>, } satisfies Record<InquiryType, InquiryRoutingConfig>,
}); });
@@ -122,6 +142,9 @@ function buildProviderConfig(prefix: string): ProviderEnvConfig {
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.LEGAL_PERSON, InquiryType.LEGAL_PERSON,
InquiryType.CAR_PLATE, InquiryType.CAR_PLATE,
InquiryType.POLICY_BY_CHASSIS,
InquiryType.POLICY_BY_PLATE,
InquiryType.POLICY_BY_NATIONAL_CODE,
]; ];
const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {}; const inquiries: Partial<Record<InquiryType, InquiryConfig>> = {};
@@ -146,6 +169,7 @@ function buildInquiryConfig(providerPrefix: string, inquiryPrefix: string): Inqu
url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '', url: process.env[`${providerPrefix}_${inquiryPrefix}_URL`] ?? '',
username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '', username: process.env[`${providerPrefix}_${inquiryPrefix}_USERNAME`] ?? '',
password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '', password: process.env[`${providerPrefix}_${inquiryPrefix}_PASSWORD`] ?? '',
apiKey: process.env[`${providerPrefix}_${inquiryPrefix}_API_KEY`] ?? '',
authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod, authMethod: (process.env[`${providerPrefix}_${inquiryPrefix}_AUTH_METHOD`] ?? 'NONE') as AuthMethod,
}; };
} }
@@ -159,6 +183,9 @@ function inquiryTypeToEnvName(inquiryType: InquiryType): string {
[InquiryType.POSTAL_CODE]: 'POSTAL_CODE', [InquiryType.POSTAL_CODE]: 'POSTAL_CODE',
[InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON', [InquiryType.LEGAL_PERSON]: 'LEGAL_PERSON',
[InquiryType.CAR_PLATE]: 'CAR_PLATE', [InquiryType.CAR_PLATE]: 'CAR_PLATE',
[InquiryType.POLICY_BY_CHASSIS]: 'POLICY_BY_CHASSIS',
[InquiryType.POLICY_BY_PLATE]: 'POLICY_BY_PLATE',
[InquiryType.POLICY_BY_NATIONAL_CODE]: 'POLICY_BY_NATIONAL_CODE',
}; };
return mapping[inquiryType]; return mapping[inquiryType];
} }

View File

@@ -14,6 +14,9 @@ export class GenericInquiryResponseDto {
@ApiPropertyOptional() @ApiPropertyOptional()
message?: string; message?: string;
@ApiPropertyOptional()
messageFa?: string;
@ApiPropertyOptional({ type: Object }) @ApiPropertyOptional({ type: Object })
data?: Record<string, unknown>; data?: Record<string, unknown>;

View File

@@ -7,6 +7,60 @@ export class PersonInquiryDataDto {
@ApiProperty({ example: '1370-05-15' }) @ApiProperty({ example: '1370-05-15' })
birthDate!: string; birthDate!: string;
@ApiPropertyOptional({ example: 'علی محمدی' }) @ApiPropertyOptional({ example: 'سهیل حاجی زاده' })
fullName?: string; fullName?: string;
@ApiPropertyOptional({ example: '4311402422' })
Nin?: string;
@ApiPropertyOptional({ example: 'سهيل' })
Name?: string;
@ApiPropertyOptional({ example: 'حاجي زاده' })
Family?: string;
@ApiPropertyOptional({ example: 'كوروش' })
FatherName?: string;
@ApiPropertyOptional({ example: 'د41' })
Shenasnameseri?: string;
@ApiPropertyOptional({ example: '709633' })
Shenasnameserial?: string;
@ApiPropertyOptional({ example: '0' })
ShenasnameNo?: string;
@ApiPropertyOptional({ example: '13781124' })
BirthDate?: string;
@ApiPropertyOptional({ example: '1' })
Gender?: string;
@ApiPropertyOptional({ example: '0' })
OfficeCode?: string;
@ApiPropertyOptional({ example: '0' })
BookNo?: string;
@ApiPropertyOptional({ example: '0' })
BookRow?: string;
@ApiPropertyOptional({ example: '0' })
DeathStatus?: string;
@ApiPropertyOptional()
Message?: string;
@ApiPropertyOptional()
DeathDate?: string;
@ApiPropertyOptional()
ExceptionMessage?: string;
@ApiPropertyOptional({ example: '1349689554' })
Zipcode?: string;
@ApiPropertyOptional()
ZipcodeDesc?: string;
} }

View File

@@ -16,6 +16,9 @@ export class PersonInquiryResponseDto {
@ApiPropertyOptional() @ApiPropertyOptional()
message?: string; message?: string;
@ApiPropertyOptional()
messageFa?: string;
@ApiPropertyOptional({ type: PersonInquiryDataDto }) @ApiPropertyOptional({ type: PersonInquiryDataDto })
data?: PersonInquiryDataDto; data?: PersonInquiryDataDto;

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class PolicyByChassisRequestDto {
@ApiProperty({ example: 'NAAM01E15HK123456', description: 'Vehicle chassis number' })
@IsString()
@IsNotEmpty()
chassisNo!: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class PolicyByNationalCodeRequestDto {
@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;
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class PolicyByPlateRequestDto {
@ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' })
@IsString()
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' })
@IsString()
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })
plk1!: string;
@ApiProperty({ example: 'ب', description: 'Middle plate letter' })
@IsString()
@IsNotEmpty()
plk2!: string;
@ApiProperty({ example: '345', description: 'Three digits on the right side of the plate' })
@IsString()
@Matches(/^\d{3}$/, { message: 'plk3 must be exactly 3 digits' })
plk3!: string;
@ApiProperty({ example: '67', description: 'Plate serial' })
@IsString()
@IsNotEmpty()
plksrl!: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class RealEstateRequestDto {
@ApiProperty({ example: '5098961130', 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: '1349689554', description: 'Postal code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'postalCode must be exactly 10 digits' })
postalCode!: string;
}

View File

@@ -1,27 +1,47 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator'; import { IsIn, IsNotEmpty, IsOptional, IsString, Length, Matches } from 'class-validator';
export class SayahRequestDto { export class SayahRequestDto {
@ApiProperty({ example: '1', description: '1 for natural person (always use "1")' }) @ApiPropertyOptional({ example: '1', description: '1 for natural person' })
@IsOptional()
@IsString() @IsString()
@IsIn(['1']) @IsIn(['1'])
accountOwnerType!: string; accountOwnerType?: string;
@ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' }) @ApiProperty({ example: '4311402422', description: 'Iranian national code (10 digits)' })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(10, 10) @Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalId must be exactly 10 digits' }) @Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalId!: string; nationalCode!: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({
type: String,
example: '',
nullable: true,
description: 'Legal entity ID; use empty string for natural persons',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
@Transform(({ value }: { value: unknown }) => {
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'object') {
return '';
}
return String(value);
})
legalId?: string | null; legalId?: string | null;
@ApiProperty({ example: 'IR800560611828005105117001', description: 'Iranian Sheba ID (26 characters)' }) @ApiProperty({
example: 'IR800560611828005105117001',
description: 'Iranian Sheba ID (26 characters)',
})
@Transform(({ value }: { value?: string }) => value?.toUpperCase())
@IsString() @IsString()
@Length(26, 26) @Length(26, 26)
@Matches(/^IR\d{24}$/, { message: 'shebaId must start with IR and contain 24 digits' }) @Matches(/^IR\d{24}$/, { message: 'sheba must start with IR and contain 24 digits' })
shebaId!: string; sheba!: string;
} }

View File

@@ -28,6 +28,10 @@ import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto';
import { PostalCodeRequestDto } from './dto/postal-code-request.dto'; import { PostalCodeRequestDto } from './dto/postal-code-request.dto';
import { ShahkarRequestDto } from './dto/shahkar-request.dto'; import { ShahkarRequestDto } from './dto/shahkar-request.dto';
import { SayahRequestDto } from './dto/sayah-request.dto'; import { SayahRequestDto } from './dto/sayah-request.dto';
import { RealEstateRequestDto } from './dto/real-estate-request.dto';
import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto';
import { InquiryService } from './inquiry.service'; import { InquiryService } from './inquiry.service';
/** /**
@@ -65,10 +69,14 @@ export class InquiryController {
@ApiOperation({ summary: 'Real estate inquiry' }) @ApiOperation({ summary: 'Real estate inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto }) @ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireRealEstate( async inquireRealEstate(
@Body() payload: Record<string, unknown>, @Body() payload: RealEstateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string }, @Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> { ): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(InquiryType.REAL_ESTATE, payload, req); return this.inquireGeneric(
InquiryType.REAL_ESTATE,
payload as unknown as Record<string, unknown>,
req,
);
} }
@Post('sheba') @Post('sheba')
@@ -127,6 +135,54 @@ export class InquiryController {
return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req); return this.inquireGeneric(InquiryType.LEGAL_PERSON, payload, req);
} }
@Post('policyByChassis')
@InquiryAccess(InquiryType.POLICY_BY_CHASSIS)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by chassis number' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByChassis(
@Body() payload: PolicyByChassisRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_CHASSIS,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('policyByPlate')
@InquiryAccess(InquiryType.POLICY_BY_PLATE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by national vehicle plate' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByPlate(
@Body() payload: PolicyByPlateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_PLATE,
payload as unknown as Record<string, unknown>,
req,
);
}
@Post('policyByNationalCode')
@InquiryAccess(InquiryType.POLICY_BY_NATIONAL_CODE)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@ApiOperation({ summary: 'Policy inquiry by national code' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquirePolicyByNationalCode(
@Body() payload: PolicyByNationalCodeRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): Promise<BaseInquiryResponseDto<Record<string, unknown>>> {
return this.inquireGeneric(
InquiryType.POLICY_BY_NATIONAL_CODE,
payload as unknown as Record<string, unknown>,
req,
);
}
private async inquireGeneric( private async inquireGeneric(
inquiryType: InquiryType, inquiryType: InquiryType,
payload: Record<string, unknown>, payload: Record<string, unknown>,

View File

@@ -5,8 +5,11 @@ import { ProviderName } from '../common/enums/provider-name.enum';
import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto'; import { BaseInquiryResponseDto } from '../common/dto/base-inquiry-response.dto';
import { NormalizedErrorDto } from '../common/dto/normalized-error.dto'; import { NormalizedErrorDto } from '../common/dto/normalized-error.dto';
import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper';
import { InquiryException } from '../common/exceptions/inquiry.exception'; import { InquiryException } from '../common/exceptions/inquiry.exception';
import { InquiryLogService } from '../logging/inquiry-log.service'; import { InquiryLogService } from '../logging/inquiry-log.service';
import { translateError } from '../common/helpers/translate-error.helper';
import { buildNormalizedError } from '../common/constants/error-messages';
import { import {
LegacyInquiryPayload, LegacyInquiryPayload,
PersonInquiryPayload, PersonInquiryPayload,
@@ -23,76 +26,19 @@ export class InquiryService {
private readonly inquiryLogService: InquiryLogService, private readonly inquiryLogService: InquiryLogService,
) {} ) {}
/**
* Person inquiry — unified business flow with provider fallback and audit logging.
*/
async inquirePerson( async inquirePerson(
dto: PersonInquiryRequestDto, dto: PersonInquiryRequestDto,
requestId: string, requestId: string,
): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> { ): Promise<BaseInquiryResponseDto<PersonInquiryDataDto>> {
const trackingCode = generateTrackingCode(); return this.inquire(
const payload: PersonInquiryPayload = { InquiryType.PERSON,
nationalCode: dto.nationalCode, {
birthDate: dto.birthDate, nationalCode: dto.nationalCode,
dateHasPostfix: (dto as PersonInquiryRequestDto & { dateHasPostfix?: number }).dateHasPostfix, birthDate: dto.birthDate,
}; dateHasPostfix: dto.dateHasPostfix,
},
const start = Date.now(); requestId,
) as unknown as Promise<BaseInquiryResponseDto<PersonInquiryDataDto>>;
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( async inquire(
@@ -111,19 +57,20 @@ export class InquiryService {
trackingCode, trackingCode,
); );
const response = this.buildGenericSuccessResponse( const response = buildInquiryResponse({
result.data, success: true,
result.provider, provider: result.provider,
trackingCode, trackingCode,
result.duration, message: `${this.getInquiryLabel(inquiryType)} completed successfully`,
`${this.getInquiryLabel(inquiryType)} completed successfully`, duration: result.duration,
); data: this.toResponseData(result.data),
});
await this.persistLog({ await this.persistLog({
inquiryType, inquiryType,
provider: result.provider, provider: result.provider,
requestPayload: payload, requestPayload: payload,
responsePayload: response.data, responsePayload: response.data ?? undefined,
status: InquiryStatus.SUCCESS, status: InquiryStatus.SUCCESS,
duration: result.duration, duration: result.duration,
requestId, requestId,
@@ -133,20 +80,20 @@ export class InquiryService {
return response; return response;
} catch (error) { } catch (error) {
const duration = Date.now() - start; const duration = Date.now() - start;
const normalized = this.extractError(error); const { normalized, provider } = this.extractFailure(error);
const response: BaseInquiryResponseDto<Record<string, unknown>> = { const response = buildInquiryResponse({
success: false, success: false,
provider: 'GATEWAY', provider: provider ?? 'GATEWAY',
trackingCode, trackingCode,
message: normalized.message, message: normalized.message,
error: normalized,
duration, duration,
}; error: normalized,
});
await this.persistLog({ await this.persistLog({
inquiryType, inquiryType,
provider: ProviderName.HAMTA, provider: (provider as ProviderName | undefined) ?? ProviderName.HAMTA,
requestPayload: payload, requestPayload: payload,
status: InquiryStatus.FAILURE, status: InquiryStatus.FAILURE,
duration, duration,
@@ -171,45 +118,21 @@ export class InquiryService {
}); });
} }
private buildSuccessResponse( private toResponseData(result: unknown): Record<string, unknown> {
result: PersonInquiryResult, if (this.isPersonInquiryResult(result)) {
provider: string, const raw =
trackingCode: string, result.raw && typeof result.raw === 'object' && !Array.isArray(result.raw)
duration: number, ? (result.raw as Record<string, unknown>)
message: string, : {};
): BaseInquiryResponseDto<PersonInquiryDataDto> {
return { return {
success: true,
provider,
trackingCode,
message,
duration,
data: {
nationalCode: result.nationalCode, nationalCode: result.nationalCode,
birthDate: result.birthDate, birthDate: result.birthDate,
fullName: result.fullName, ...(result.fullName ? { fullName: result.fullName } : {}),
}, ...raw,
}; };
} }
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) { if (result && typeof result === 'object' && 'raw' in result) {
const raw = (result as { raw: unknown }).raw; const raw = (result as { raw: unknown }).raw;
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : { raw }; return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : { raw };
@@ -220,20 +143,35 @@ export class InquiryService {
: { raw: result }; : { raw: result };
} }
private isPersonInquiryResult(result: unknown): result is PersonInquiryResult {
return (
typeof result === 'object' &&
result !== null &&
'nationalCode' in result &&
'birthDate' in result
);
}
private getInquiryLabel(inquiryType: InquiryType): string { private getInquiryLabel(inquiryType: InquiryType): string {
return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' '); return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' ');
} }
private extractError(error: unknown): NormalizedErrorDto { private extractFailure(error: unknown): {
normalized: NormalizedErrorDto;
provider?: string;
} {
if (error instanceof InquiryException) { if (error instanceof InquiryException) {
return error.normalizedError; return { normalized: translateError(error.normalizedError), provider: error.provider };
} }
if (error && typeof error === 'object' && 'normalizedError' in error) { if (error && typeof error === 'object' && 'normalizedError' in error) {
return (error as { normalizedError: NormalizedErrorDto }).normalizedError; return {
normalized: translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError),
};
} }
return { return {
code: 'INQUIRY_FAILED', normalized: buildNormalizedError('INQUIRY_FAILED', {
message: error instanceof Error ? error.message : 'Inquiry failed', message: error instanceof Error ? error.message : 'Inquiry failed',
}),
}; };
} }

View File

@@ -7,6 +7,8 @@ import { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { API_KEY_HEADER } from './common/constants/app.constants'; import { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger'; import { OpenTelemetryNestLogger } from './otel-nest-logger';
import { buildValidationErrorResponse } from './common/helpers/validation-error.helper';
import { AppException } from './common/exceptions/app-exception';
import { import {
recordHttpRequestEnd, recordHttpRequestEnd,
recordHttpRequestStart, recordHttpRequestStart,
@@ -46,16 +48,39 @@ async function bootstrap(): Promise<void> {
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
transform: true, transform: true,
transformOptions: { enableImplicitConversion: true }, transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors) => {
const { normalizedError } = buildValidationErrorResponse(errors);
return new AppException('VALIDATION_ERROR', {
message: normalizedError.message,
messageFa: normalizedError.messageFa,
details: normalizedError.details,
});
},
}), }),
); );
const swaggerConfig = new DocumentBuilder() const baseUrlProd = process.env.BASE_URL_PROD;
const clientUrl = process.env.CLIENT_URL;
const documentBuilder = new DocumentBuilder()
.setTitle('External Services Gateway') .setTitle('External Services Gateway')
.setDescription( .setDescription(
'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' + 'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' +
'per-user inquiry access, audit logging, and normalized inquiry responses.', 'per-user inquiry access, audit logging, and normalized inquiry responses.',
) )
.setVersion('1.0') .setVersion('1.0')
.addServer("http://localhost:8085")
.addServer("http://192.168.20.22:8085");
if (clientUrl) {
documentBuilder.addServer(clientUrl);
}
if (baseUrlProd) {
documentBuilder.addServer(baseUrlProd);
}
const swaggerConfig = documentBuilder
.addBearerAuth( .addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'bearer', 'bearer',

View File

@@ -1,4 +1,5 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { AxiosError } from 'axios';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
@@ -10,6 +11,12 @@ import { withRetry } from '../../common/helpers/retry.helper';
import { withTimeout } from '../../common/helpers/timeout.helper'; import { withTimeout } from '../../common/helpers/timeout.helper';
import { RequestLogger } from '../../common/helpers/request-logger.helper'; import { RequestLogger } from '../../common/helpers/request-logger.helper';
import { ProviderConfigSlice } from '../interfaces/provider-config.interface'; import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
import {
AppErrorCode,
buildNormalizedError,
isAppErrorCode,
resolveProviderPublicMessage,
} from '../../common/constants/error-messages';
/** /**
* Abstract base for all provider adapters. * Abstract base for all provider adapters.
@@ -120,26 +127,83 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
} }
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto { protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
return { const code = this.toProviderErrorCode(partial.code);
code: partial.code ?? 'PROVIDER_ERROR', const publicMessage =
message: partial.message ?? 'Provider request failed', code === 'PROVIDER_ERROR'
? resolveProviderPublicMessage(partial.providerMessage ?? partial.message)
: {
message: partial.message ?? 'Provider request failed',
messageFa: partial.messageFa,
};
return buildNormalizedError(code, {
message: publicMessage.message,
messageFa: publicMessage.messageFa,
providerMessage: partial.providerMessage, providerMessage: partial.providerMessage,
providerCode: partial.providerCode, providerCode: partial.providerCode,
}; providerTrackingCode: partial.providerTrackingCode,
details: partial.details,
conflict: partial.conflict,
});
}
private toProviderErrorCode(code?: string): AppErrorCode {
if (!code) return 'PROVIDER_ERROR';
if (isAppErrorCode(code)) return code;
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'PROVIDER_TIMEOUT';
if (code === 'ECONNRESET' || code === 'NETWORK_ERROR') return 'PROVIDER_NETWORK_ERROR';
return 'PROVIDER_ERROR';
} }
protected formatProviderError( protected formatProviderError(
providerMessage?: string, providerMessage?: string,
providerCode?: string, providerCode?: string,
fallbackMessage = 'Provider returned an error', fallbackMessage = 'Provider request failed',
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
): Error { ): Error {
const message = providerMessage?.trim() || fallbackMessage;
const upstreamCode = providerCode?.trim();
const normalized = this.normalizeError({ const normalized = this.normalizeError({
code: 'PROVIDER_ERROR', code: upstreamCode,
message: fallbackMessage, message: fallbackMessage,
providerMessage, providerMessage: message,
providerCode, providerCode: upstreamCode,
...extras,
}); });
const err = new Error(normalized.message); const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
protected formatAxiosError(error: AxiosError): Error {
const data = error.response?.data;
if (typeof data === 'string') {
const faultMatch = data.match(
/<(?:\w+:)?faultstring[^>]*>([\s\S]*?)<\/(?:\w+:)?faultstring>/i,
);
if (faultMatch?.[1]) {
return this.formatProviderError(
faultMatch[1].trim().replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>'),
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
}
const providerCode = error.code ?? String(error.response?.status ?? 'NETWORK_ERROR');
const code = error.response ? 'PROVIDER_BAD_RESPONSE' : providerCode;
const normalized = this.normalizeError({
code,
message: error.message,
providerMessage: error.message,
providerCode,
details: [
{
field: 'providerStatus',
constraints: [String(error.response?.status ?? providerCode)],
},
],
});
const err = new Error(error.message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err; return err;
} }

View File

@@ -6,6 +6,7 @@ import { InquiryProvider } from '../../common/interfaces/inquiry-provider.interf
import { InquiryRoutingConfig } from '../../config/configuration'; import { InquiryRoutingConfig } from '../../config/configuration';
import { HamtaProvider } from '../implementations/hamta.provider'; import { HamtaProvider } from '../implementations/hamta.provider';
import { MoallemProvider } from '../implementations/moallem.provider'; import { MoallemProvider } from '../implementations/moallem.provider';
import { ParsianProvider } from '../implementations/parsian.provider';
import { TejaratNouProvider } from '../implementations/tejaratnou.provider'; import { TejaratNouProvider } from '../implementations/tejaratnou.provider';
/** /**
@@ -22,11 +23,13 @@ export class ProviderFactory {
private readonly configService: ConfigService, private readonly configService: ConfigService,
hamta: HamtaProvider, hamta: HamtaProvider,
moallem: MoallemProvider, moallem: MoallemProvider,
parsian: ParsianProvider,
tejaratnou: TejaratNouProvider, tejaratnou: TejaratNouProvider,
) { ) {
this.registry = new Map<ProviderName, InquiryProvider>([ this.registry = new Map<ProviderName, InquiryProvider>([
[ProviderName.HAMTA, hamta], [ProviderName.HAMTA, hamta],
[ProviderName.MOALLEM, moallem], [ProviderName.MOALLEM, moallem],
[ProviderName.PARSIAN, parsian],
[ProviderName.TEJARATNOU, tejaratnou], [ProviderName.TEJARATNOU, tejaratnou],
]); ]);
} }

View File

@@ -1,8 +1,17 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios'; import { AxiosError, AxiosInstance } from 'axios';
import {
createOutboundAxiosInstance,
isOutboundHttpDebugEnabled,
} from '../../common/helpers/http-client.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import {
AppErrorCode,
buildNormalizedError,
} from '../../common/constants/error-messages';
import { AmitisAuthServiceConfig } from '../../config/configuration'; import { AmitisAuthServiceConfig } from '../../config/configuration';
import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenDocument } from '../schemas/general-token.schema';
import { GeneralTokenService } from '../services/general-token.service'; import { GeneralTokenService } from '../services/general-token.service';
@@ -22,6 +31,8 @@ interface CentInsurTokenResponse {
ExpiresIn?: number | string; ExpiresIn?: number | string;
expiresIn?: number | string; expiresIn?: number | string;
expires_in?: number | string; expires_in?: number | string;
IsSucceed?: boolean;
LoginStatus?: number;
data?: CentInsurTokenResponse; data?: CentInsurTokenResponse;
} }
@@ -49,7 +60,7 @@ export class AmitisProvider {
private readonly generalTokenService: GeneralTokenService, private readonly generalTokenService: GeneralTokenService,
) { ) {
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!; this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
this.httpClient = axios.create({ this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl, baseURL: this.config.baseUrl,
timeout: this.config.timeout, timeout: this.config.timeout,
}); });
@@ -102,25 +113,36 @@ export class AmitisProvider {
username: string, username: string,
password: string, password: string,
): Promise<string> { ): Promise<string> {
// Use FormData for multipart/form-data format const loginBody = new URLSearchParams({
const FormData = require('form-data'); username: username.toLowerCase(),
const formData = new FormData(); password,
formData.append('username', username); });
formData.append('password', password);
try { try {
const loginUrl = `${this.config.baseUrl}${this.config.loginPath}`;
this.logger.log(
`AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`,
);
const response = await this.httpClient.post<CentInsurTokenResponse>( const response = await this.httpClient.post<CentInsurTokenResponse>(
this.config.loginPath, this.config.loginPath,
formData, loginBody.toString(),
{ {
headers: { headers: {
...formData.getHeaders(), 'Content-Type': 'application/x-www-form-urlencoded',
}, },
}, },
); );
if (isOutboundHttpDebugEnabled()) {
this.logger.log(
`AMITIS login ← ${response.status} | provider=${providerName} | inquiry=${inquiryType} | bodyKeys=${Object.keys(response.data ?? {}).join(',') || 'none'}`,
);
}
const token = this.extractToken(response.data); const token = this.extractToken(response.data);
await this.saveToken(token, providerName, inquiryType, username, 'login'); await this.saveToken(token, providerName, inquiryType, username, password, 'login');
return token.accessToken; return token.accessToken;
} catch (error) { } catch (error) {
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error); throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
@@ -144,6 +166,7 @@ export class AmitisProvider {
providerName, providerName,
inquiryType, inquiryType,
latestToken.username, latestToken.username,
latestToken.clientSecret,
'refresh', 'refresh',
); );
return token.accessToken; return token.accessToken;
@@ -160,15 +183,16 @@ export class AmitisProvider {
providerName: ProviderName, providerName: ProviderName,
inquiryType: InquiryType, inquiryType: InquiryType,
username: string, username: string,
password: string,
scope: string, scope: string,
): Promise<void> { ): Promise<void> {
await this.generalTokenService.create({ await this.generalTokenService.create({
serviceProvider: this.getTokenKey(providerName, inquiryType), serviceProvider: this.getTokenKey(providerName, inquiryType),
tokenType: token.tokenType, tokenType: token.tokenType,
url: this.config.baseUrl, url: this.config.baseUrl,
clientId: '', clientId: username.toLowerCase(),
clientSecret: '', clientSecret: password,
username, username: username.toLowerCase(),
scope, scope,
accessToken: token.accessToken, accessToken: token.accessToken,
refreshToken: token.refreshToken, refreshToken: token.refreshToken,
@@ -182,11 +206,32 @@ export class AmitisProvider {
fallbackRefreshToken?: string, fallbackRefreshToken?: string,
): AmitisAuthToken { ): AmitisAuthToken {
const body = responseBody.data ?? responseBody; const body = responseBody.data ?? responseBody;
const loginStatus = body.LoginStatus;
if (loginStatus === 2 || body.IsSucceed === false) {
throw this.createAuthError(
loginStatus === 2
? 'AMITIS username or password is invalid for this service account'
: `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`,
loginStatus === 2 ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_REJECTED',
);
}
if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) {
throw this.createAuthError(
`AMITIS login rejected (LoginStatus=${loginStatus})`,
'PROVIDER_REJECTED',
);
}
const accessToken = const accessToken =
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token; body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
if (!accessToken) { if (!accessToken || !String(accessToken).trim()) {
throw new Error('AMITIS auth response did not include an access token'); const bodyPreview = JSON.stringify(responseBody).slice(0, 500);
throw new Error(
`AMITIS auth response did not include an access token | body=${bodyPreview}`,
);
} }
const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60); const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60);
@@ -226,17 +271,55 @@ export class AmitisProvider {
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`; return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
} }
private createAuthError(message: string, code: AppErrorCode): Error {
const normalized = buildNormalizedError(code, {
message,
providerMessage: message,
providerCode: code,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
private createProviderAuthError(message: string, error: unknown): Error {
const providerCode =
error instanceof AxiosError
? String(error.response?.status ?? error.code ?? 'NETWORK_ERROR')
: undefined;
const providerMessage = error instanceof Error ? error.message : String(error);
const normalized = buildNormalizedError('PROVIDER_AUTH_FAILED', {
message,
providerMessage,
providerCode,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err;
}
private toAuthError(message: string, error: unknown): Error { private toAuthError(message: string, error: unknown): Error {
if (error instanceof Error && 'normalizedError' in error) {
return error;
}
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
const status = error.response?.status ?? 'NETWORK_ERROR'; const status = error.response?.status ?? 'NETWORK_ERROR';
return new Error(`${message}: ${status} ${error.message}`); const body =
error.response?.data !== undefined
? JSON.stringify(error.response.data).slice(0, 500)
: undefined;
return this.createProviderAuthError(
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
error,
);
} }
if (error instanceof Error) { if (error instanceof Error) {
return new Error(`${message}: ${error.message}`); return this.createProviderAuthError(`${message}: ${error.message}`, error);
} }
return new Error(`${message}: ${String(error)}`); return this.createProviderAuthError(`${message}: ${String(error)}`, error);
} }
} }

View File

@@ -1,6 +1,21 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
buildCivilRegistrationFullName,
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import {
describeShahkarResponse,
getShahkarProviderError,
parseShahkarInqueryResult,
} from '../../common/helpers/shahkar-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration'; import { ProviderEnvConfig } from '../../config/configuration';
@@ -10,6 +25,7 @@ import {
LegacyApiProvider, LegacyApiProvider,
LegacyInquiryPayload, LegacyInquiryPayload,
LegacyInquiryResult, LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract'; } from '../shared/legacy-api.provider.abstract';
interface CivilRegistrationPayload extends LegacyInquiryPayload { interface CivilRegistrationPayload extends LegacyInquiryPayload {
@@ -35,9 +51,9 @@ interface ShahkarPayload extends LegacyInquiryPayload {
interface SayahPayload extends LegacyInquiryPayload { interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string; accountOwnerType?: string;
nationalId?: string; nationalCode?: string;
legalId?: string | null; legalId?: string | null;
shebaId?: string; sheba?: string;
} }
/** /**
@@ -51,6 +67,7 @@ export class HamtaProvider extends LegacyApiProvider {
InquiryType.SHEBA, InquiryType.SHEBA,
InquiryType.SHAHKAR, InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.REAL_ESTATE,
]; ];
private readonly hamtaConfig: ProviderEnvConfig; private readonly hamtaConfig: ProviderEnvConfig;
@@ -96,6 +113,7 @@ export class HamtaProvider extends LegacyApiProvider {
); );
const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE); const inquiryConfig = this.getInquiryConfig(InquiryType.POSTAL_CODE);
this.assertAmitisCredentials(InquiryType.POSTAL_CODE, inquiryConfig);
const token = await this.amitisProvider.getAccessToken( const token = await this.amitisProvider.getAccessToken(
ProviderName.HAMTA, ProviderName.HAMTA,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
@@ -106,11 +124,11 @@ export class HamtaProvider extends LegacyApiProvider {
try { try {
const response = await axios.get<Record<string, unknown>>( const response = await axios.get<Record<string, unknown>>(
`${inquiryConfig.url}/AddressByPostcode`, `${inquiryConfig.url}/AddressByPostcode`,
{ mergeOutboundAxiosConfig({
params: { PostalCode: postalCode }, params: { PostalCode: postalCode },
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
timeout: this.hamtaConfig.timeout, timeout: this.hamtaConfig.timeout,
}, }),
); );
return { raw: response.data }; return { raw: response.data };
@@ -127,7 +145,7 @@ export class HamtaProvider extends LegacyApiProvider {
private async inquireCivilRegistration( private async inquireCivilRegistration(
payload: CivilRegistrationPayload, payload: CivilRegistrationPayload,
): Promise<{ raw: unknown }> { ): Promise<PersonInquiryResult> {
const nationalCode = this.getRequiredString( const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN, payload.nationalCode ?? payload.NIN,
'nationalCode', 'nationalCode',
@@ -140,37 +158,39 @@ export class HamtaProvider extends LegacyApiProvider {
const response = await axios.post<string>( const response = await axios.post<string>(
inquiryConfig.url, inquiryConfig.url,
this.buildCivilRegistrationEnvelope( this.buildCivilRegistrationEnvelope(
payload,
nationalCode, nationalCode,
birthDate, birthDate,
inquiryConfig.username, inquiryConfig.username,
inquiryConfig.password, inquiryConfig.password,
), ),
{ mergeOutboundAxiosConfig({
headers: { headers: {
'Content-Type': 'text/xml; charset=utf-8', 'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"', SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
}, },
timeout: this.hamtaConfig.timeout, timeout: this.hamtaConfig.timeout,
responseType: 'text', responseType: 'text',
}, }),
); );
const result = this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'); const civilRegistration = parseCiiEstelamResult(response.data);
const providerError = getCivilRegistrationProviderError(civilRegistration);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
const fullName = buildCivilRegistrationFullName(civilRegistration);
return { return {
raw: { nationalCode,
nationalCode, birthDate,
birthDate, fullName: fullName || undefined,
result, raw: civilRegistration,
soap: response.data,
},
}; };
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
throw this.formatProviderError( throw this.formatAxiosError(error);
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
} }
throw error; throw error;
} }
@@ -194,24 +214,32 @@ export class HamtaProvider extends LegacyApiProvider {
inquiryConfig.username, inquiryConfig.username,
inquiryConfig.password, inquiryConfig.password,
), ),
{ mergeOutboundAxiosConfig({
headers: { headers: {
'Content-Type': 'text/xml; charset=utf-8', 'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"', SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
}, },
timeout: this.hamtaConfig.timeout, timeout: this.hamtaConfig.timeout,
responseType: 'text', responseType: 'text',
}, }),
); );
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult'); const shahkarFields = parseShahkarInqueryResult(response.data);
const providerError = getShahkarProviderError(shahkarFields);
if (providerError) {
this.nestLogger.warn(
`SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return { return {
raw: { raw: {
nationalCode, nationalCode,
mobileNo, mobileNo,
result, ...shahkarFields,
soap: response.data,
}, },
}; };
} catch (error) { } catch (error) {
@@ -226,12 +254,13 @@ export class HamtaProvider extends LegacyApiProvider {
} }
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> { private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType'); const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalId ?? ''; const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null; const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId'); const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
this.assertAmitisCredentials(InquiryType.SHEBA, inquiryConfig);
const token = await this.amitisProvider.getAccessToken( const token = await this.amitisProvider.getAccessToken(
ProviderName.HAMTA, ProviderName.HAMTA,
InquiryType.SHEBA, InquiryType.SHEBA,
@@ -240,11 +269,7 @@ export class HamtaProvider extends LegacyApiProvider {
); );
try { try {
const response = await axios.post<{ const response = await axios.post<SayahApiResponse>(
IsSucceed?: boolean;
Errors?: Array<{ Code?: string; Message?: string }>;
Result?: unknown;
}>(
inquiryConfig.url, inquiryConfig.url,
{ {
AccountOwnerType: accountOwnerType, AccountOwnerType: accountOwnerType,
@@ -252,20 +277,18 @@ export class HamtaProvider extends LegacyApiProvider {
LegalId: legalId, LegalId: legalId,
ShebaId: shebaId, ShebaId: shebaId,
}, },
{ mergeOutboundAxiosConfig({
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: this.hamtaConfig.timeout, timeout: this.hamtaConfig.timeout,
}, }),
); );
if (!response.data.IsSucceed && response.data.Errors) { const providerError = getSayahProviderError(response.data);
throw this.formatProviderError( if (providerError) {
this.formatErrors(response.data.Errors), throw this.formatProviderError(providerError.message, providerError.code);
'SAYAH_HAS_ERROR',
);
} }
return { raw: response.data }; return { raw: response.data };
@@ -281,19 +304,35 @@ export class HamtaProvider extends LegacyApiProvider {
} }
private buildCivilRegistrationEnvelope( private buildCivilRegistrationEnvelope(
payload: CivilRegistrationPayload,
nationalCode: string, nationalCode: string,
birthDate: string, birthDate: string,
username: string, username: string,
password: string, password: string,
): string { ): string {
const birthDateCompact = birthDate.replace(/-/g, '');
const dateHasPostfix = payload.dateHasPostfix ?? 0;
return `<?xml version="1.0" encoding="utf-8"?> return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body> <soap:Body>
<SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/"> <SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<NIN>${this.escapeXml(nationalCode)}</NIN> <req>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate> <Nin>${this.escapeXml(nationalCode)}</Nin>
<Shenasnameserial>0</Shenasnameserial>
<ShenasnameNo>0</ShenasnameNo>
<BirthDate>${this.escapeXml(birthDateCompact)}</BirthDate>
<DateHasPostfix>${dateHasPostfix}</DateHasPostfix>
<Gender>0</Gender>
<OfficeCode>0</OfficeCode>
<BookNo>0</BookNo>
<NameHasPrefix>0</NameHasPrefix>
<NameHasPostFix>0</NameHasPostFix>
<FamilyHasPrefix>0</FamilyHasPrefix>
<FamilyHasPostFix>0</FamilyHasPostFix>
</req>
<Username>${this.escapeXml(username)}</Username> <Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password> <Password>${this.escapeXml(password)}</Password>
</SubmitInqDteStsWithPstCod> </SubmitInqDteStsWithPstCod>
@@ -322,9 +361,17 @@ export class HamtaProvider extends LegacyApiProvider {
</soap:Envelope>`; </soap:Envelope>`;
} }
private extractSoapValue(xml: string, tagName: string): string | null { private assertAmitisCredentials(
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`)); inquiryType: InquiryType,
return match ? this.decodeXml(match[1].trim()) : null; inquiryConfig: { username: string; password: string },
): void {
const envPrefix = inquiryType.replace(/_INQUIRY$/, '');
if (!inquiryConfig.username?.trim() || !inquiryConfig.password?.trim()) {
throw this.formatProviderError(
`${envPrefix} AMITIS credentials are missing. Set HAMTA_${envPrefix}_USERNAME and HAMTA_${envPrefix}_PASSWORD in .env`,
'PROVIDER_NOT_CONFIGURED',
);
}
} }
private getRequiredString(value: unknown, fieldName: string): string { private getRequiredString(value: unknown, fieldName: string): string {
@@ -340,21 +387,12 @@ export class HamtaProvider extends LegacyApiProvider {
private escapeXml(value: string): string { private escapeXml(value: string): string {
return value return value
.replace(/&/g, '&') .replace(/&/g, '&amp;')
.replace(/</g, '<') .replace(/</g, '&lt;')
.replace(/>/g, '>') .replace(/>/g, '&gt;')
.replace(/"/g, '"') .replace(/"/g, '&quot;')
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }
private decodeXml(value: string): string {
return value
.replace(/'/g, "'")
.replace(/"/g, '"')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/&/g, '&');
}
} }
// Made with Bob // Made with Bob

View File

@@ -1,6 +1,21 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
buildCivilRegistrationFullName,
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getShahkarProviderError,
parseShahkarInqueryResult,
describeShahkarResponse,
} from '../../common/helpers/shahkar-response.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderEnvConfig } from '../../config/configuration'; import { ProviderEnvConfig } from '../../config/configuration';
@@ -10,6 +25,7 @@ import {
LegacyApiProvider, LegacyApiProvider,
LegacyInquiryPayload, LegacyInquiryPayload,
LegacyInquiryResult, LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract'; } from '../shared/legacy-api.provider.abstract';
interface CivilRegistrationPayload extends LegacyInquiryPayload { interface CivilRegistrationPayload extends LegacyInquiryPayload {
@@ -35,9 +51,9 @@ interface ShahkarPayload extends LegacyInquiryPayload {
interface SayahPayload extends LegacyInquiryPayload { interface SayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string; accountOwnerType?: string;
nationalId?: string; nationalCode?: string;
legalId?: string | null; legalId?: string | null;
shebaId?: string; sheba?: string;
} }
/** /**
@@ -51,6 +67,7 @@ export class MoallemProvider extends LegacyApiProvider {
InquiryType.SHEBA, InquiryType.SHEBA,
InquiryType.SHAHKAR, InquiryType.SHAHKAR,
InquiryType.POSTAL_CODE, InquiryType.POSTAL_CODE,
InquiryType.REAL_ESTATE,
]; ];
private readonly moallemConfig: ProviderEnvConfig; private readonly moallemConfig: ProviderEnvConfig;
@@ -106,11 +123,11 @@ export class MoallemProvider extends LegacyApiProvider {
try { try {
const response = await axios.get<Record<string, unknown>>( const response = await axios.get<Record<string, unknown>>(
`${inquiryConfig.url}/AddressByPostcode`, `${inquiryConfig.url}/AddressByPostcode`,
{ mergeOutboundAxiosConfig({
params: { PostalCode: postalCode }, params: { PostalCode: postalCode },
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
timeout: this.moallemConfig.timeout, timeout: this.moallemConfig.timeout,
}, }),
); );
return { raw: response.data }; return { raw: response.data };
@@ -127,7 +144,7 @@ export class MoallemProvider extends LegacyApiProvider {
private async inquireCivilRegistration( private async inquireCivilRegistration(
payload: CivilRegistrationPayload, payload: CivilRegistrationPayload,
): Promise<{ raw: unknown }> { ): Promise<PersonInquiryResult> {
const nationalCode = this.getRequiredString( const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN, payload.nationalCode ?? payload.NIN,
'nationalCode', 'nationalCode',
@@ -140,37 +157,39 @@ export class MoallemProvider extends LegacyApiProvider {
const response = await axios.post<string>( const response = await axios.post<string>(
inquiryConfig.url, inquiryConfig.url,
this.buildCivilRegistrationEnvelope( this.buildCivilRegistrationEnvelope(
payload,
nationalCode, nationalCode,
birthDate, birthDate,
inquiryConfig.username, inquiryConfig.username,
inquiryConfig.password, inquiryConfig.password,
), ),
{ mergeOutboundAxiosConfig({
headers: { headers: {
'Content-Type': 'text/xml; charset=utf-8', 'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"', SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
}, },
timeout: this.moallemConfig.timeout, timeout: this.moallemConfig.timeout,
responseType: 'text', responseType: 'text',
}, }),
); );
const result = this.extractSoapValue(response.data, 'SabtInqueryResult'); const civilRegistration = parseCiiEstelamResult(response.data);
const providerError = getCivilRegistrationProviderError(civilRegistration);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
const fullName = buildCivilRegistrationFullName(civilRegistration);
return { return {
raw: { nationalCode,
nationalCode, birthDate,
birthDate, fullName: fullName || undefined,
result, raw: civilRegistration,
soap: response.data,
},
}; };
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
throw this.formatProviderError( throw this.formatAxiosError(error);
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
} }
throw error; throw error;
} }
@@ -194,24 +213,32 @@ export class MoallemProvider extends LegacyApiProvider {
inquiryConfig.username, inquiryConfig.username,
inquiryConfig.password, inquiryConfig.password,
), ),
{ mergeOutboundAxiosConfig({
headers: { headers: {
'Content-Type': 'text/xml; charset=utf-8', 'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"', SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"',
}, },
timeout: this.moallemConfig.timeout, timeout: this.moallemConfig.timeout,
responseType: 'text', responseType: 'text',
}, }),
); );
const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult'); const shahkarFields = parseShahkarInqueryResult(response.data);
const providerError = getShahkarProviderError(shahkarFields);
if (providerError) {
this.nestLogger.warn(
`SHAHKAR provider business failure | ${describeShahkarResponse(shahkarFields)}`,
);
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
}
return { return {
raw: { raw: {
nationalCode, nationalCode,
mobileNo, mobileNo,
result, ...shahkarFields,
soap: response.data,
}, },
}; };
} catch (error) { } catch (error) {
@@ -226,10 +253,10 @@ export class MoallemProvider extends LegacyApiProvider {
} }
private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> { private async inquireSayah(payload: SayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = this.getRequiredString(payload.accountOwnerType, 'accountOwnerType'); const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalId ?? ''; const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null; const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.shebaId, 'shebaId'); const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA); const inquiryConfig = this.getInquiryConfig(InquiryType.SHEBA);
const token = await this.amitisProvider.getAccessToken( const token = await this.amitisProvider.getAccessToken(
@@ -240,11 +267,7 @@ export class MoallemProvider extends LegacyApiProvider {
); );
try { try {
const response = await axios.post<{ const response = await axios.post<SayahApiResponse>(
IsSucceed?: boolean;
Errors?: Array<{ Code?: string; Message?: string }>;
Result?: unknown;
}>(
inquiryConfig.url, inquiryConfig.url,
{ {
AccountOwnerType: accountOwnerType, AccountOwnerType: accountOwnerType,
@@ -252,20 +275,18 @@ export class MoallemProvider extends LegacyApiProvider {
LegalId: legalId, LegalId: legalId,
ShebaId: shebaId, ShebaId: shebaId,
}, },
{ mergeOutboundAxiosConfig({
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: this.moallemConfig.timeout, timeout: this.moallemConfig.timeout,
}, }),
); );
if (!response.data.IsSucceed && response.data.Errors) { const providerError = getSayahProviderError(response.data);
throw this.formatProviderError( if (providerError) {
this.formatErrors(response.data.Errors), throw this.formatProviderError(providerError.message, providerError.code);
'SAYAH_HAS_ERROR',
);
} }
return { raw: response.data }; return { raw: response.data };
@@ -281,22 +302,38 @@ export class MoallemProvider extends LegacyApiProvider {
} }
private buildCivilRegistrationEnvelope( private buildCivilRegistrationEnvelope(
payload: CivilRegistrationPayload,
nationalCode: string, nationalCode: string,
birthDate: string, birthDate: string,
username: string, username: string,
password: string, password: string,
): string { ): string {
const birthDateCompact = birthDate.replace(/-/g, '');
const dateHasPostfix = payload.dateHasPostfix ?? 0;
return `<?xml version="1.0" encoding="utf-8"?> return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body> <soap:Body>
<SabtInquery xmlns="http://tempuri.org/"> <SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<NIN>${this.escapeXml(nationalCode)}</NIN> <req>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate> <Nin>${this.escapeXml(nationalCode)}</Nin>
<Shenasnameserial>0</Shenasnameserial>
<ShenasnameNo>0</ShenasnameNo>
<BirthDate>${this.escapeXml(birthDateCompact)}</BirthDate>
<DateHasPostfix>${dateHasPostfix}</DateHasPostfix>
<Gender>0</Gender>
<OfficeCode>0</OfficeCode>
<BookNo>0</BookNo>
<NameHasPrefix>0</NameHasPrefix>
<NameHasPostFix>0</NameHasPostFix>
<FamilyHasPrefix>0</FamilyHasPrefix>
<FamilyHasPostFix>0</FamilyHasPostFix>
</req>
<Username>${this.escapeXml(username)}</Username> <Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password> <Password>${this.escapeXml(password)}</Password>
</SabtInquery> </SubmitInqDteStsWithPstCod>
</soap:Body> </soap:Body>
</soap:Envelope>`; </soap:Envelope>`;
} }
@@ -340,10 +377,10 @@ export class MoallemProvider extends LegacyApiProvider {
private escapeXml(value: string): string { private escapeXml(value: string): string {
return value return value
.replace(/&/g, '&') .replace(/&/g, '&amp;')
.replace(/</g, '<') .replace(/</g, '&lt;')
.replace(/>/g, '>') .replace(/>/g, '&gt;')
.replace(/"/g, '"') .replace(/"/g, '&quot;')
.replace(/'/g, '&apos;'); .replace(/'/g, '&apos;');
} }

View File

@@ -0,0 +1,597 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
buildCivilRegistrationFullName,
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getCarPolicyProviderError,
parseFirstCarPolicy,
} from '../../common/helpers/soap-car-policy.helper';
import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { ProviderEnvConfig } from '../../config/configuration';
import { BaseProvider } from '../base/base-provider.abstract';
import { AmitisProvider } from './amitis.provider';
import {
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload {
nationalCode?: string;
birthDate?: string;
NIN?: string;
BirthDate?: string;
dateHasPostfix?: number;
}
interface ParsianShahkarPayload extends LegacyInquiryPayload {
nationalCode?: string;
nationalCod?: string;
NationalCod?: string;
mobileNo?: string;
MobileNo?: string;
mobileNumber?: string;
MobileNumber?: string;
}
interface ParsianShahkarResponse {
errorNams?: string | null;
ErrorNams?: string | null;
comment?: string | null;
id?: string | null;
requestId?: string | null;
response?: number | string;
result?: string | null;
}
interface ParsianSayahPayload extends LegacyInquiryPayload {
accountOwnerType?: string;
nationalCode?: string;
legalId?: string | null;
sheba?: string;
}
interface ParsianSayahResponse {
ReturnValue?: boolean;
returnValue?: boolean;
HasError?: boolean;
hasError?: boolean;
Errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null;
IsSucceed?: boolean;
isSucceed?: boolean;
Result?: unknown;
result?: unknown;
}
interface ParsianPolicyByChassisPayload extends LegacyInquiryPayload {
chassisNo?: string;
}
interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
nationalCode?: string;
}
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
nationalCode?: string;
plk1?: string;
plk2?: string;
plk3?: string;
plksrl?: string;
}
@Injectable()
export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyInquiryResult> {
readonly name = ProviderName.PARSIAN;
readonly supportedInquiryTypes = [
InquiryType.PERSON,
InquiryType.SHAHKAR,
InquiryType.SHEBA,
InquiryType.POLICY_BY_CHASSIS,
InquiryType.POLICY_BY_PLATE,
InquiryType.POLICY_BY_NATIONAL_CODE,
];
constructor(
configService: ConfigService,
private readonly amitisProvider: AmitisProvider,
) {
super(configService.get<ProviderEnvConfig>('parsian')!);
}
isEnabled(): boolean {
const personConfig = this.config.inquiries[InquiryType.PERSON];
const shahkarConfig = this.config.inquiries[InquiryType.SHAHKAR];
const shebaConfig = this.config.inquiries[InquiryType.SHEBA];
const policyByChassisConfig = this.config.inquiries[InquiryType.POLICY_BY_CHASSIS];
const policyByPlateConfig = this.config.inquiries[InquiryType.POLICY_BY_PLATE];
const policyByNationalCodeConfig =
this.config.inquiries[InquiryType.POLICY_BY_NATIONAL_CODE];
return (
this.config.enabled &&
(this.hasSoapCredentials(personConfig) ||
(Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
(Boolean(shebaConfig?.url) &&
Boolean(shebaConfig?.username) &&
Boolean(shebaConfig?.password)) ||
this.hasSoapCredentials(policyByChassisConfig) ||
this.hasSoapCredentials(policyByPlateConfig) ||
this.hasSoapCredentials(policyByNationalCodeConfig))
);
}
protected async callProvider(
inquiryType: InquiryType,
payload: LegacyInquiryPayload,
_context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> {
if (inquiryType === InquiryType.PERSON) {
return this.inquireCivilRegistration(payload as ParsianCivilRegistrationPayload);
}
if (inquiryType === InquiryType.SHAHKAR) {
return this.inquireShahkar(payload as ParsianShahkarPayload);
}
if (inquiryType === InquiryType.SHEBA) {
return this.inquireSayah(payload as ParsianSayahPayload);
}
if (inquiryType === InquiryType.POLICY_BY_CHASSIS) {
return this.inquirePolicyByChassis(payload as ParsianPolicyByChassisPayload);
}
if (inquiryType === InquiryType.POLICY_BY_PLATE) {
return this.inquirePolicyByPlate(payload as ParsianPolicyByPlatePayload);
}
if (inquiryType === InquiryType.POLICY_BY_NATIONAL_CODE) {
return this.inquirePolicyByNationalCode(payload as ParsianPolicyByNationalCodePayload);
}
throw this.formatProviderError(
undefined,
'UNSUPPORTED_INQUIRY',
`Parsian does not support ${inquiryType}`,
);
}
private async inquireShahkar(payload: ParsianShahkarPayload): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.nationalCod ?? payload.NationalCod,
'nationalCode',
);
const mobileNumber = this.getRequiredString(
payload.mobileNumber ?? payload.MobileNumber ?? payload.mobileNo ?? payload.MobileNo,
'mobileNo',
);
const inquiryConfig = this.config.inquiries[InquiryType.SHAHKAR];
if (!inquiryConfig?.url || !inquiryConfig.apiKey) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian Shahkar inquiry is not configured',
);
}
try {
const response = await axios.get<ParsianShahkarResponse>(
inquiryConfig.url,
mergeOutboundAxiosConfig({
params: {
nationalCode,
mobileNumber,
},
headers: {
Accept: 'application/json',
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
},
timeout: this.config.timeout,
}),
);
const body = response.data;
const responseCode = Number(body.response);
if (!Number.isFinite(responseCode) || responseCode !== 200) {
throw this.formatProviderError(
body.comment ?? body.errorNams ?? body.ErrorNams ?? 'Shahkar inquiry failed',
String(body.response ?? 'PROVIDER_ERROR'),
undefined,
{
providerTrackingCode: body.requestId ?? body.id ?? undefined,
},
);
}
return {
raw: {
nationalCode,
mobileNumber,
...body,
},
};
} catch (error) {
if (error instanceof AxiosError) {
const data = error.response?.data as ParsianShahkarResponse | undefined;
throw this.formatProviderError(
data?.comment ?? data?.errorNams ?? data?.ErrorNams ?? error.message,
String(data?.response ?? error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireCivilRegistration(
payload: ParsianCivilRegistrationPayload,
): Promise<PersonInquiryResult> {
const nationalCode = this.getRequiredString(
payload.nationalCode ?? payload.NIN,
'nationalCode',
);
const birthDate = this.getRequiredString(payload.birthDate ?? payload.BirthDate, 'birthDate');
const inquiryConfig = this.config.inquiries[InquiryType.PERSON];
if (!this.hasSoapCredentials(inquiryConfig)) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian person inquiry is not configured',
);
}
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCivilRegistrationEnvelope(
payload,
nationalCode,
birthDate,
inquiryConfig.username,
inquiryConfig.password,
),
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"',
},
timeout: this.config.timeout,
responseType: 'text',
}),
);
const civilRegistration = parseCiiEstelamResult(response.data);
const providerError = getCivilRegistrationProviderError(civilRegistration);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
const fullName = buildCivilRegistrationFullName(civilRegistration);
return {
nationalCode,
birthDate,
fullName: fullName || undefined,
raw: civilRegistration,
};
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquirePolicyByChassis(
payload: ParsianPolicyByChassisPayload,
): Promise<{ raw: unknown }> {
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo');
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_CHASSIS,
'CIIWSPolicyChassis',
{ ChassisNo: chassisNo },
);
return {
raw: {
chassisNo,
...policy,
},
};
}
private async inquirePolicyByNationalCode(
payload: ParsianPolicyByNationalCodePayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_NATIONAL_CODE,
'CIIWSPolicyNationalId',
{ NationalId: nationalCode },
);
return {
raw: {
nationalCode,
...policy,
},
};
}
private async inquirePolicyByPlate(
payload: ParsianPolicyByPlatePayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const plk1 = this.getRequiredString(payload.plk1, 'plk1');
const plk2 = this.getRequiredString(payload.plk2, 'plk2');
const plk3 = this.getRequiredString(payload.plk3, 'plk3');
const plksrl = this.getRequiredString(payload.plksrl, 'plksrl');
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_PLATE,
'CIIWSPolicyVehicleMeli',
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
);
const policyNationalId = policy.NtnlId?.trim() ?? '';
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
throw this.formatProviderError(
'عدم تطابق اطلاعات',
'INQUIRY_NO_MATCH',
undefined,
{
conflict: {
nationalCode,
NtnlId: policyNationalId,
},
},
);
}
return {
raw: {
nationalCode,
plk1,
plk2,
plk3,
plksrl,
...policy,
},
};
}
private nationalIdsMatch(requested: string, fromPolicy: string): boolean {
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
}
private async callCarPolicySoap(
inquiryType: InquiryType,
methodName: string,
fields: Record<string, string>,
): Promise<Record<string, string>> {
const inquiryConfig = this.config.inquiries[inquiryType];
if (!this.hasSoapCredentials(inquiryConfig)) {
throw this.formatProviderError(
undefined,
undefined,
`Parsian ${inquiryType} is not configured`,
);
}
try {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCarPolicyEnvelope(
methodName,
fields,
inquiryConfig.username,
inquiryConfig.password,
),
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"http://tempuri.org/ICarAllPlcys/${methodName}"`,
},
timeout: this.config.timeout,
responseType: 'text',
}),
);
const policy = parseFirstCarPolicy(response.data);
const providerError = getCarPolicyProviderError(response.data, policy);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return policy;
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
private async inquireSayah(payload: ParsianSayahPayload): Promise<{ raw: unknown }> {
const accountOwnerType = payload.accountOwnerType ?? '1';
const nationalId = payload.nationalCode ?? '';
const legalId = payload.legalId ?? null;
const shebaId = this.getRequiredString(payload.sheba, 'sheba');
const inquiryConfig = this.config.inquiries[InquiryType.SHEBA];
if (!inquiryConfig?.url || !inquiryConfig.username || !inquiryConfig.password) {
throw this.formatProviderError(
undefined,
undefined,
'Parsian Sayah inquiry is not configured',
);
}
const token = await this.amitisProvider.getAccessToken(
ProviderName.PARSIAN,
InquiryType.SHEBA,
inquiryConfig.username,
inquiryConfig.password,
);
try {
const response = await axios.post<SayahApiResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
NationalId: nationalId,
LegalId: legalId,
ShebaId: shebaId,
},
mergeOutboundAxiosConfig({
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.config.timeout,
}),
);
const providerError = getSayahProviderError(response.data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: response.data };
} catch (error) {
if (error instanceof AxiosError) {
const data = error.response?.data as ParsianSayahResponse | undefined;
throw this.formatProviderError(
this.formatErrors(data?.Errors ?? data?.errors) ?? error.message,
String(error.response?.status ?? 'NETWORK_ERROR'),
);
}
throw error;
}
}
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 hasSoapCredentials(
inquiryConfig: ProviderEnvConfig['inquiries'][InquiryType],
): inquiryConfig is NonNullable<ProviderEnvConfig['inquiries'][InquiryType]> {
return Boolean(inquiryConfig?.url && inquiryConfig.username && inquiryConfig.password);
}
private buildCarPolicyEnvelope(
methodName: string,
fields: Record<string, string>,
username: string,
password: string,
): string {
const fieldXml = Object.entries(fields)
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
.join('\n');
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>
<${methodName} xmlns="http://tempuri.org/">
${fieldXml}
<Username>${this.escapeXml(username)}</Username>
<PassWrod>${this.escapeXml(password)}</PassWrod>
</${methodName}>
</soap:Body>
</soap:Envelope>`;
}
private buildCivilRegistrationEnvelope(
payload: ParsianCivilRegistrationPayload,
nationalCode: string,
birthDate: string,
username: string,
password: string,
): string {
const birthDateCompact = birthDate.replace(/-/g, '');
const dateHasPostfix = payload.dateHasPostfix ?? 0;
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>
<SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<req>
<Nin>${this.escapeXml(nationalCode)}</Nin>
<Name xsi:nil="true"/>
<Family xsi:nil="true"/>
<Fathername xsi:nil="true"/>
<Shenasnameseri xsi:nil="true"/>
<Shenasnameserial>0</Shenasnameserial>
<ShenasnameNo>0</ShenasnameNo>
<BirthDate>${this.escapeXml(birthDateCompact)}</BirthDate>
<DateHasPostfix>${dateHasPostfix}</DateHasPostfix>
<Gender>0</Gender>
<OfficeCode>0</OfficeCode>
<BookNo>0</BookNo>
<NameHasPrefix>0</NameHasPrefix>
<NameHasPostFix>0</NameHasPostFix>
<FamilyHasPrefix>0</FamilyHasPrefix>
<FamilyHasPostFix>0</FamilyHasPostFix>
</req>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</SubmitInqDteStsWithPstCod>
</soap:Body>
</soap:Envelope>`;
}
private escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
private hasErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): boolean {
if (!errors) return false;
return Array.isArray(errors) ? errors.length > 0 : Object.keys(errors).length > 0;
}
private formatErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): string | undefined {
if (!this.hasErrors(errors)) return undefined;
if (Array.isArray(errors)) {
return errors.map((error) => `${error.Code}: ${error.Message}`).join(', ');
}
return Object.entries(errors ?? {})
.map(([code, field]) => `${field} (code: ${code})`)
.join(', ');
}
}

View File

@@ -1,10 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosError, AxiosInstance } from 'axios'; import axios, { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance, mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper'; import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { resolveProviderPublicMessage } from '../../common/constants/error-messages';
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface'; import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
import { GeneralTokenService } from '../services/general-token.service'; import { GeneralTokenService } from '../services/general-token.service';
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service'; import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
@@ -51,7 +53,7 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
private readonly cachedInquiryResultService: CachedInquiryResultService, private readonly cachedInquiryResultService: CachedInquiryResultService,
) { ) {
this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!; this.config = this.configService.get<TejaratNouConfig>('tejaratnou')!;
this.httpClient = axios.create({ this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.inquiryBaseUrl, baseURL: this.config.inquiryBaseUrl,
timeout: this.config.timeout, timeout: this.config.timeout,
headers: { headers: {
@@ -190,19 +192,22 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
} }
private createProviderError(providerMessage: string, providerCode: string): Error { private createProviderError(providerMessage: string, providerCode: string): Error {
const publicMessage = resolveProviderPublicMessage(providerMessage);
const error = new Error(providerMessage); const error = new Error(providerMessage);
( (
error as Error & { error as Error & {
normalizedError: { normalizedError: {
code: string; code: string;
message: string; message: string;
messageFa: string;
providerMessage: string; providerMessage: string;
providerCode: string; providerCode: string;
}; };
} }
).normalizedError = { ).normalizedError = {
code: 'PROVIDER_ERROR', code: 'PROVIDER_ERROR',
message: providerMessage, message: publicMessage.message,
messageFa: publicMessage.messageFa,
providerMessage, providerMessage,
providerCode, providerCode,
}; };
@@ -227,13 +232,13 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
password: this.config.password, password: this.config.password,
scope: 'api-gateway access-management', scope: 'api-gateway access-management',
}), }),
{ mergeOutboundAxiosConfig({
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C', Cookie: 'cookiesession1=678A8C5D2222753B93723D81AA53FF8C',
}, },
timeout: this.config.timeout, timeout: this.config.timeout,
}, }),
); );
const jsonData = response.data; const jsonData = response.data;

View File

@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose';
import { ProviderFactory } from './factory/provider.factory'; import { ProviderFactory } from './factory/provider.factory';
import { HamtaProvider } from './implementations/hamta.provider'; import { HamtaProvider } from './implementations/hamta.provider';
import { MoallemProvider } from './implementations/moallem.provider'; import { MoallemProvider } from './implementations/moallem.provider';
import { ParsianProvider } from './implementations/parsian.provider';
import { TejaratNouProvider } from './implementations/tejaratnou.provider'; import { TejaratNouProvider } from './implementations/tejaratnou.provider';
import { AmitisProvider } from './implementations/amitis.provider'; import { AmitisProvider } from './implementations/amitis.provider';
import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service'; import { ProviderOrchestratorService } from './strategy/provider-orchestrator.service';
@@ -24,6 +25,7 @@ import { CachedInquiryResultService } from './services/cached-inquiry-result.ser
providers: [ providers: [
HamtaProvider, HamtaProvider,
MoallemProvider, MoallemProvider,
ParsianProvider,
TejaratNouProvider, TejaratNouProvider,
AmitisProvider, AmitisProvider,
GeneralTokenService, GeneralTokenService,

View File

@@ -1,4 +1,11 @@
import axios, { AxiosError, AxiosInstance } from 'axios'; import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import {
CentInsurApiResponse,
describeCentInsurResponse,
getCentInsurProviderError,
} from '../../common/helpers/centinsur-response.helper';
import { getSayahProviderError } from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
@@ -51,7 +58,7 @@ export abstract class LegacyApiProvider extends BaseProvider<
) { ) {
super(config); super(config);
this.providerConfig = config; this.providerConfig = config;
this.httpClient = axios.create({ this.httpClient = createOutboundAxiosInstance({
timeout: config.timeout, timeout: config.timeout,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -129,28 +136,31 @@ export abstract class LegacyApiProvider extends BaseProvider<
const body = response.data; const body = response.data;
if ('IsSucceed' in body) {
this.nestLogger.log(
`${inquiryType} provider response | http=${response.status} | ${describeCentInsurResponse(body)}`,
);
}
// Handle Sayah/Sheba API format (ReturnValue/HasError) // Handle Sayah/Sheba API format (ReturnValue/HasError)
if ('ReturnValue' in body || 'HasError' in body) { if ('ReturnValue' in body || 'HasError' in body) {
if (body.HasError) { const providerError = getSayahProviderError(body);
// Format error message from Errors object if (providerError) {
const errorMessages = body.Errors throw this.formatProviderError(providerError.message, providerError.code);
? Object.entries(body.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ')
: 'Request failed';
throw this.formatProviderError(
errorMessages,
'SAYAH_ERROR',
);
} }
return { raw: body }; return { raw: body };
} }
// Handle CentInsur API format (IsSucceed/Result) // Handle CentInsur API format (IsSucceed/Result)
if ('IsSucceed' in body) { if ('IsSucceed' in body) {
if (!body.IsSucceed) { const providerError = getCentInsurProviderError(body, inquiryType);
throw this.formatProviderError( if (providerError) {
body.Result?.ErrorMessage ?? 'Request failed', this.nestLogger.warn(
'API_ERROR', `${inquiryType} provider business failure | ${describeCentInsurResponse(body)}`,
); );
throw this.formatProviderError(providerError.message, providerError.code, undefined, {
providerTrackingCode: providerError.providerTrackingCode,
});
} }
return { raw: body }; return { raw: body };
} }
@@ -168,22 +178,21 @@ export abstract class LegacyApiProvider extends BaseProvider<
const data = error.response?.data as LegacyProviderApiResponse | undefined; const data = error.response?.data as LegacyProviderApiResponse | undefined;
// Check for Sayah/Sheba format error // Check for Sayah/Sheba format error
if (data && ('ReturnValue' in data || 'HasError' in data) && data.HasError) { if (data && ('ReturnValue' in data || 'HasError' in data)) {
const errorMessages = data.Errors const providerError = getSayahProviderError(data);
? Object.entries(data.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ') if (providerError) {
: error.message; throw this.formatProviderError(providerError.message, providerError.code);
throw this.formatProviderError( }
errorMessages,
String(error.response?.status ?? 'SAYAH_ERROR'),
);
} }
// Check for CentInsur format error // Check for CentInsur format error
if (data && 'IsSucceed' in data && !data.IsSucceed) { if (data && 'IsSucceed' in data) {
throw this.formatProviderError( const providerError = getCentInsurProviderError(data, inquiryType);
data.Result?.ErrorMessage ?? error.message, if (providerError) {
String(error.response?.status ?? 'API_ERROR'), throw this.formatProviderError(providerError.message, providerError.code, undefined, {
); providerTrackingCode: providerError.providerTrackingCode,
});
}
} }
throw this.formatProviderError( throw this.formatProviderError(
@@ -207,7 +216,6 @@ export abstract class LegacyApiProvider extends BaseProvider<
return { return {
NationalId: payload.nationalCode ?? payload.nationalId ?? payload.NationalId, NationalId: payload.nationalCode ?? payload.nationalId ?? payload.NationalId,
PostalCode: payload.postalCode ?? payload.PostalCode, PostalCode: payload.postalCode ?? payload.PostalCode,
...payload,
}; };
} }

View File

@@ -5,6 +5,8 @@ import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { InquiryException } from '../../common/exceptions/inquiry.exception'; import { InquiryException } from '../../common/exceptions/inquiry.exception';
import { ProviderFactory } from '../factory/provider.factory'; import { ProviderFactory } from '../factory/provider.factory';
import { translateError } from '../../common/helpers/translate-error.helper';
import { buildNormalizedError } from '../../common/constants/error-messages';
export interface OrchestrationResult<T> { export interface OrchestrationResult<T> {
data: T; data: T;
@@ -30,8 +32,9 @@ export class ProviderOrchestratorService {
if (providers.length === 0) { if (providers.length === 0) {
throw new InquiryException('No providers available for inquiry', { throw new InquiryException('No providers available for inquiry', {
code: 'NO_PROVIDERS', ...buildNormalizedError('NO_PROVIDERS', {
message: `No enabled providers configured for ${inquiryType}`, message: `No enabled providers configured for ${inquiryType}`,
}),
}); });
} }
@@ -55,39 +58,47 @@ export class ProviderOrchestratorService {
} catch (error) { } catch (error) {
const normalized = this.extractError(error); const normalized = this.extractError(error);
errors.push(normalized); errors.push(normalized);
const errorSummary = normalized.providerMessage ?? normalized.message;
const hasNextProvider = providers.indexOf(provider) < providers.length - 1; const hasNextProvider = providers.indexOf(provider) < providers.length - 1;
this.logger.warn( this.logger.warn(
hasNextProvider hasNextProvider
? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback` ? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}`
: `Provider ${provider.name} failed for ${inquiryType}, no fallback available`, : `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`,
); );
this.logger.debug( this.logger.warn(
`Attempt duration: ${Date.now() - attemptStart}ms | error: ${normalized.message}`, `Attempt duration: ${Date.now() - attemptStart}ms | providerCode=${normalized.providerCode ?? normalized.code} | providerMessage=${normalized.providerMessage ?? normalized.message}`,
); );
} }
} }
throw new InquiryException('All providers failed', { const lastError = errors[errors.length - 1]!;
code: providers.length === 1 ? 'PROVIDER_SERVICE_NOT_AVAILABLE' : 'ALL_PROVIDERS_FAILED',
message: if (providers.length === 1) {
providers.length === 1 throw new InquiryException(lastError.message, lastError, undefined, providers[0]!.name);
? `${providers[0].name} service is not available` }
: errors.map((e) => e.message).join('; '),
providerMessage: errors[0]?.providerMessage, const lastProvider = providers[providers.length - 1]!.name;
providerCode: errors[0]?.providerCode, throw new InquiryException(
}); errors.map((error) => error.message).join('; '),
buildNormalizedError('ALL_PROVIDERS_FAILED', {
message: errors.map((error) => error.message).join('; '),
providerMessage: lastError.providerMessage ?? lastError.message,
providerCode: lastError.providerCode ?? lastError.code,
}),
undefined,
lastProvider,
);
} }
private extractError(error: unknown): NormalizedErrorDto { private extractError(error: unknown): NormalizedErrorDto {
if (error && typeof error === 'object' && 'normalizedError' in error) { if (error && typeof error === 'object' && 'normalizedError' in error) {
return (error as { normalizedError: NormalizedErrorDto }).normalizedError; return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError);
} }
if (error instanceof InquiryException) { if (error instanceof InquiryException) {
return error.normalizedError; return translateError(error.normalizedError);
} }
return { return buildNormalizedError('UNKNOWN_ERROR', {
code: 'UNKNOWN_ERROR',
message: error instanceof Error ? error.message : 'Unknown provider error', message: error instanceof Error ? error.message : 'Unknown provider error',
}; });
} }
} }

View File

@@ -94,7 +94,10 @@
"SHEBA_INQUIRY", "SHEBA_INQUIRY",
"SHAHKAR_INQUIRY", "SHAHKAR_INQUIRY",
"LEGAL_PERSON_INQUIRY", "LEGAL_PERSON_INQUIRY",
"CAR_PLATE_INQUIRY" "CAR_PLATE_INQUIRY",
"POLICY_BY_CHASSIS_INQUIRY",
"POLICY_BY_PLATE_INQUIRY",
"POLICY_BY_NATIONAL_CODE_INQUIRY"
], ],
"requestLimitPerMinute": 60, "requestLimitPerMinute": 60,
"requestLimitPerDay": 10000, "requestLimitPerDay": 10000,

View File

@@ -1,14 +1,13 @@
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
HttpException,
HttpStatus,
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface'; import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { UserRateLimitService } from './user-rate-limit.service'; import { UserRateLimitService } from './user-rate-limit.service';
import { AppException } from '../common/exceptions/app-exception';
/** /**
* Per-user rate limits from the user document (requestLimitPerMinute / requestLimitPerDay). * Per-user rate limits from the user document (requestLimitPerMinute / requestLimitPerDay).
@@ -36,7 +35,7 @@ export class UserRateLimitGuard implements CanActivate {
); );
if (!allowed) { if (!allowed) {
throw new HttpException('User rate limit exceeded', HttpStatus.TOO_MANY_REQUESTS); throw new AppException('RATE_LIMIT_EXCEEDED');
} }
void this.usersService.incrementTotalRequests(user.id); void this.usersService.incrementTotalRequests(user.id);

View File

@@ -106,8 +106,15 @@ console.debug = (...args: unknown[]) => {
originalConsole.debug(...args); originalConsole.debug(...args);
}; };
export function shutdownTelemetry(): Promise<void> { export async function shutdownTelemetry(): Promise<void> {
return sdk.shutdown(); try {
await sdk.shutdown();
} catch (error) {
originalConsole.warn(
'OpenTelemetry shutdown failed:',
error instanceof Error ? error.message : String(error),
);
}
} }
export function recordHttpRequestStart(attributes: Record<string, string>): void { export function recordHttpRequestStart(attributes: Record<string, string>): void {

View File

@@ -1,9 +1,5 @@
import { import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable, Injectable,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose'; import { Model, Types } from 'mongoose';
@@ -19,6 +15,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { UserMapper } from './mappers/user.mapper'; import { UserMapper } from './mappers/user.mapper';
import { User, UserDocument } from './schemas/user.schema'; import { User, UserDocument } from './schemas/user.schema';
import { UserResponseDto } from './dto/user-response.dto'; import { UserResponseDto } from './dto/user-response.dto';
import { AppException } from '../common/exceptions/app-exception';
export interface RequestContext { export interface RequestContext {
ip?: string; ip?: string;
@@ -36,7 +33,7 @@ export class UsersService {
async findById(id: string): Promise<UserDocument> { async findById(id: string): Promise<UserDocument> {
const user = await this.userModel.findById(id); const user = await this.userModel.findById(id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new AppException('USER_NOT_FOUND');
} }
return user; return user;
} }
@@ -68,7 +65,7 @@ export class UsersService {
$or: [{ username }, { email }], $or: [{ username }, { email }],
}); });
if (existing) { if (existing) {
throw new ConflictException('Username or email already exists'); throw new AppException('USERNAME_OR_EMAIL_EXISTS');
} }
const passwordHash = await this.passwordService.hash(dto.password); const passwordHash = await this.passwordService.hash(dto.password);
@@ -117,7 +114,7 @@ export class UsersService {
const email = dto.email.toLowerCase().trim(); const email = dto.email.toLowerCase().trim();
const conflict = await this.userModel.findOne({ email, _id: { $ne: id } }); const conflict = await this.userModel.findOne({ email, _id: { $ne: id } });
if (conflict) { if (conflict) {
throw new ConflictException('Email already in use'); throw new AppException('EMAIL_ALREADY_IN_USE');
} }
user.email = email; user.email = email;
} }
@@ -149,7 +146,7 @@ export class UsersService {
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
const user = await this.findById(id); const user = await this.findById(id);
if (user._id.toString() === actor.id) { if (user._id.toString() === actor.id) {
throw new BadRequestException('Cannot block your own account'); throw new AppException('CANNOT_BLOCK_SELF');
} }
user.isBlocked = true; user.isBlocked = true;
user.refreshToken = undefined; user.refreshToken = undefined;
@@ -194,7 +191,7 @@ export class UsersService {
): Promise<void> { ): Promise<void> {
const user = await this.userModel.findById(id).select('+password +refreshToken'); const user = await this.userModel.findById(id).select('+password +refreshToken');
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new AppException('USER_NOT_FOUND');
} }
user.password = await this.passwordService.hash(dto.newPassword); user.password = await this.passwordService.hash(dto.newPassword);
@@ -238,10 +235,10 @@ export class UsersService {
private assertCanAssignRole(actorRole: Role, targetRole: Role): void { private assertCanAssignRole(actorRole: Role, targetRole: Role): void {
if (targetRole === Role.SUPER_ADMIN && actorRole !== Role.SUPER_ADMIN) { if (targetRole === Role.SUPER_ADMIN && actorRole !== Role.SUPER_ADMIN) {
throw new ForbiddenException('Only SUPER_ADMIN can assign SUPER_ADMIN role'); throw new AppException('SUPER_ADMIN_ROLE_REQUIRED');
} }
if (!ADMIN_ROLES.includes(actorRole)) { if (!ADMIN_ROLES.includes(actorRole)) {
throw new ForbiddenException('Insufficient permissions to manage users'); throw new AppException('INSUFFICIENT_USER_MANAGEMENT_PERMISSION');
} }
} }

View File

@@ -0,0 +1,221 @@
import { INestApplication } from '@nestjs/common';
import request = require('supertest');
import { AuthController } from '../../src/auth/auth.controller';
import { AuthService } from '../../src/auth/auth.service';
import { UsersController } from '../../src/users/users.controller';
import { UsersService } from '../../src/users/users.service';
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../src/auth/guards/roles.guard';
import { ClientType } from '../../src/common/enums/client-type.enum';
import { Role } from '../../src/common/enums/role.enum';
import { AppException } from '../../src/common/exceptions/app-exception';
import { createHttpTestApp } from '../support/app';
import { bearerTokenGuard, passGuard } from '../support/auth';
import { expectErrorEnvelope } from '../support/assertions';
function userResponse(overrides: Record<string, unknown> = {}) {
return {
id: 'client-1',
fullName: 'Client One',
username: 'client-one',
email: 'client@example.com',
role: Role.USER,
clientType: ClientType.EXTERNAL,
appName: 'client-app',
clientName: 'Client One',
isActive: true,
isBlocked: false,
allowedInquiries: [],
requestLimitPerMinute: 60,
requestLimitPerDay: 10000,
totalRequests: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}
describe('admin API', () => {
let app: INestApplication;
const authService = {
login: jest.fn(),
refresh: jest.fn(),
logout: jest.fn(),
getProfile: jest.fn(),
};
const usersService = {
create: jest.fn(),
findAll: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
block: jest.fn(),
unblock: jest.fn(),
resetPassword: jest.fn(),
};
beforeAll(async () => {
app = await createHttpTestApp(
{
controllers: [AuthController, UsersController],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: UsersService, useValue: usersService },
],
},
(builder) =>
builder
.overrideGuard(JwtAuthGuard)
.useValue(bearerTokenGuard())
.overrideGuard(RolesGuard)
.useValue(passGuard()),
);
});
beforeEach(() => {
jest.clearAllMocks();
authService.login.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
tokenType: 'Bearer',
expiresIn: 900,
});
usersService.create.mockResolvedValue(userResponse());
usersService.update.mockResolvedValue(userResponse({ fullName: 'Updated Client' }));
usersService.block.mockResolvedValue(userResponse({ isBlocked: true }));
usersService.unblock.mockResolvedValue(userResponse({ isBlocked: false }));
usersService.resetPassword.mockResolvedValue(undefined);
});
afterAll(async () => {
await app.close();
});
it('logs in with valid credentials', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'admin', password: 'SecureP@ssw0rd' })
.expect(201);
expect(response.body).toMatchObject({
accessToken: 'access-token',
refreshToken: 'refresh-token',
});
});
it('returns invalid credentials using the normalized error envelope', async () => {
authService.login.mockRejectedValueOnce(new AppException('INVALID_CREDENTIALS'));
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'admin', password: 'WrongP@ssw0rd' })
.expect(401);
expectErrorEnvelope(response, 'INVALID_CREDENTIALS');
});
it('rejects unauthorized admin access', async () => {
const response = await request(app.getHttpServer()).post('/users').send({}).expect(401);
expectErrorEnvelope(response, 'UNAUTHORIZED');
});
it('rejects expired JWTs', async () => {
const response = await request(app.getHttpServer())
.post('/users')
.set('Authorization', 'Bearer expired')
.send({})
.expect(401);
expectErrorEnvelope(response, 'UNAUTHORIZED');
});
it('creates a client', async () => {
const response = await request(app.getHttpServer())
.post('/users')
.set('Authorization', 'Bearer admin')
.send({
fullName: 'Client One',
username: 'client-one',
email: 'client@example.com',
password: 'SecureP@ssw0rd',
role: Role.USER,
clientType: ClientType.EXTERNAL,
})
.expect(201);
expect(response.body).toMatchObject({ id: 'client-1', username: 'client-one' });
});
it('updates a client', async () => {
const response = await request(app.getHttpServer())
.patch('/users/client-1')
.set('Authorization', 'Bearer admin')
.send({ fullName: 'Updated Client' })
.expect(200);
expect(response.body.fullName).toBe('Updated Client');
});
it('disables and enables a client through block/unblock', async () => {
await request(app.getHttpServer())
.patch('/users/client-1/block')
.set('Authorization', 'Bearer admin')
.expect(200)
.expect(({ body }) => expect(body.isBlocked).toBe(true));
await request(app.getHttpServer())
.patch('/users/client-1/unblock')
.set('Authorization', 'Bearer admin')
.expect(200)
.expect(({ body }) => expect(body.isBlocked).toBe(false));
});
it('rejects invalid client configuration at the DTO layer', async () => {
const response = await request(app.getHttpServer())
.post('/users')
.set('Authorization', 'Bearer admin')
.send({
fullName: 'Client One',
username: 'client-one',
email: 'not-an-email',
password: 'short',
role: 'BAD_ROLE',
clientType: ClientType.EXTERNAL,
})
.expect(400);
expectErrorEnvelope(response, 'VALIDATION_ERROR');
expect(usersService.create).not.toHaveBeenCalled();
});
it('returns duplicate client errors', async () => {
usersService.create.mockRejectedValueOnce(new AppException('USERNAME_OR_EMAIL_EXISTS'));
const response = await request(app.getHttpServer())
.post('/users')
.set('Authorization', 'Bearer admin')
.send({
fullName: 'Client One',
username: 'client-one',
email: 'client@example.com',
password: 'SecureP@ssw0rd',
role: Role.USER,
clientType: ClientType.EXTERNAL,
})
.expect(409);
expectErrorEnvelope(response, 'USERNAME_OR_EMAIL_EXISTS');
});
it('validates reset-password requests', async () => {
const response = await request(app.getHttpServer())
.patch('/users/client-1/reset-password')
.set('Authorization', 'Bearer admin')
.send({ newPassword: 'short' })
.expect(400);
expectErrorEnvelope(response, 'VALIDATION_ERROR');
});
it.todo('delete client once the admin API exposes DELETE /users/:id');
});

View File

@@ -0,0 +1,131 @@
import { INestApplication } from '@nestjs/common';
import request = require('supertest');
import { InquiryController } from '../../src/inquiry/inquiry.controller';
import { InquiryService } from '../../src/inquiry/inquiry.service';
import { ProviderOrchestratorService } from '../../src/providers/strategy/provider-orchestrator.service';
import { InquiryLogService } from '../../src/logging/inquiry-log.service';
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
import { InquiryAccessGuard } from '../../src/auth/guards/inquiry-access.guard';
import { UserRateLimitGuard } from '../../src/rate-limit/user-rate-limit.guard';
import { inquiryEndpointCases } from '../support/inquiry-endpoints';
import { createHttpTestApp } from '../support/app';
import { authenticatedGuard, passGuard } from '../support/auth';
import {
normalizedProviderError,
providerErrorCases,
successfulProviderResult,
} from '../support/mock-provider';
import { expectErrorEnvelope, expectSuccessEnvelope } from '../support/assertions';
describe('inquiry endpoints (integration)', () => {
let app: INestApplication;
const orchestrator = {
executeWithFallback: jest.fn(),
};
const inquiryLogs = {
create: jest.fn().mockResolvedValue(undefined),
};
beforeAll(async () => {
app = await createHttpTestApp(
{
controllers: [InquiryController],
providers: [
InquiryService,
{ provide: ProviderOrchestratorService, useValue: orchestrator },
{ provide: InquiryLogService, useValue: inquiryLogs },
],
},
(builder) =>
builder
.overrideGuard(JwtAuthGuard)
.useValue(authenticatedGuard())
.overrideGuard(InquiryAccessGuard)
.useValue(passGuard())
.overrideGuard(UserRateLimitGuard)
.useValue(passGuard()),
);
});
beforeEach(() => {
jest.clearAllMocks();
orchestrator.executeWithFallback.mockResolvedValue(successfulProviderResult());
});
afterAll(async () => {
await app.close();
});
describe.each(inquiryEndpointCases)('$name', (endpoint) => {
it('returns a successful response and maps provider data', async () => {
orchestrator.executeWithFallback.mockResolvedValueOnce(
successfulProviderResult({ raw: { mapped: true, inquiryType: endpoint.inquiryType } }),
);
const response = await request(app.getHttpServer())
.post(endpoint.path)
.send(endpoint.validPayload)
.expect(201);
expectSuccessEnvelope(response);
expect(response.body.data).toMatchObject({
mapped: true,
inquiryType: endpoint.inquiryType,
});
expect(orchestrator.executeWithFallback).toHaveBeenCalledWith(
endpoint.inquiryType,
expect.objectContaining(endpoint.validPayload),
expect.objectContaining({ requestId: 'test-request-id' }),
);
});
it('returns validation errors without calling providers', async () => {
const response = await request(app.getHttpServer())
.post(endpoint.path)
.send(endpoint.invalidPayload)
.expect(400);
expectErrorEnvelope(response, 'VALIDATION_ERROR');
if (endpoint.name === 'person inquiry') {
expect(response.body.error).toMatchObject({
message: 'nationalCode must be exactly 10 digits',
messageFa: 'کد ملی باید دقیقا ۱۰ رقم باشد',
details: [
{
field: 'nationalCode',
constraints: ['nationalCode must be exactly 10 digits'],
},
],
});
}
expect(orchestrator.executeWithFallback).not.toHaveBeenCalled();
});
it('rejects malformed JSON without calling providers', async () => {
await request(app.getHttpServer())
.post(endpoint.path)
.set('Content-Type', 'application/json')
.send('{"broken":')
.expect(400);
expect(orchestrator.executeWithFallback).not.toHaveBeenCalled();
});
describe.each(providerErrorCases)('$name', (providerCase) => {
it('maps provider errors into the normalized response envelope', async () => {
orchestrator.executeWithFallback.mockRejectedValueOnce(
normalizedProviderError(providerCase.error),
);
const response = await request(app.getHttpServer())
.post(endpoint.path)
.send(endpoint.validPayload)
.expect(201);
expectErrorEnvelope(response, providerCase.code);
expect(response.body.data).toBeNull();
expect(response.body.error.providerCode).toBe(providerCase.error.providerCode);
});
});
});
});

View File

@@ -0,0 +1,78 @@
import axios from 'axios';
import { lookup } from 'dns/promises';
import * as tls from 'tls';
const smokeEnabled = process.env.ESG_SMOKE_ENABLED === 'true';
const describeSmoke = smokeEnabled ? describe : describe.skip;
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for smoke tests`);
}
return value;
}
describeSmoke('real provider smoke tests', () => {
let gatewayBaseUrl: string;
let inquiryPath: string;
let accessToken: string;
let payload: Record<string, unknown>;
let providerUrl: URL;
beforeAll(() => {
gatewayBaseUrl = requireEnv('SMOKE_GATEWAY_BASE_URL');
inquiryPath = process.env.SMOKE_INQUIRY_PATH ?? '/inquiry/person';
accessToken = requireEnv('SMOKE_ACCESS_TOKEN');
payload = JSON.parse(requireEnv('SMOKE_INQUIRY_PAYLOAD')) as Record<string, unknown>;
providerUrl = new URL(process.env.SMOKE_PROVIDER_URL ?? gatewayBaseUrl);
});
it('resolves provider DNS', async () => {
await expect(lookup(providerUrl.hostname)).resolves.toMatchObject({
address: expect.any(String),
});
});
it('negotiates SSL/TLS when provider uses HTTPS', async () => {
if (providerUrl.protocol !== 'https:') {
return;
}
await new Promise<void>((resolve, reject) => {
const socket = tls.connect(
{
host: providerUrl.hostname,
port: Number(providerUrl.port || 443),
servername: providerUrl.hostname,
timeout: 10000,
},
() => {
socket.end();
resolve();
},
);
socket.on('error', reject);
socket.on('timeout', () => {
socket.destroy();
reject(new Error('TLS probe timed out'));
});
});
});
it('reaches the gateway and performs a real successful inquiry', async () => {
const response = await axios.post(`${gatewayBaseUrl}${inquiryPath}`, payload, {
headers: { Authorization: `Bearer ${accessToken}` },
timeout: Number(process.env.SMOKE_TIMEOUT_MS ?? 30000),
validateStatus: () => true,
});
expect(response.status).toBeGreaterThanOrEqual(200);
expect(response.status).toBeLessThan(300);
expect(response.data).toMatchObject({
success: true,
trackingCode: expect.any(String),
data: expect.any(Object),
});
});
});

40
test/support/app.ts Normal file
View File

@@ -0,0 +1,40 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { ModuleMetadata } from '@nestjs/common/interfaces';
import { Test, TestingModuleBuilder } from '@nestjs/testing';
import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter';
import { AppException } from '../../src/common/exceptions/app-exception';
import { buildValidationErrorResponse } from '../../src/common/helpers/validation-error.helper';
export async function createHttpTestApp(
metadata: ModuleMetadata,
configure: (builder: TestingModuleBuilder) => TestingModuleBuilder = (builder) => builder,
): Promise<INestApplication> {
const moduleRef = await configure(Test.createTestingModule(metadata)).compile();
const app = moduleRef.createNestApplication();
app.use((req: { requestId?: string }, _res: unknown, next: () => void) => {
req.requestId = 'test-request-id';
next();
});
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors) => {
const { normalizedError } = buildValidationErrorResponse(errors);
return new AppException('VALIDATION_ERROR', {
message: normalizedError.message,
messageFa: normalizedError.messageFa,
details: normalizedError.details,
});
},
}),
);
await app.init();
return app;
}

View File

@@ -0,0 +1,23 @@
import { Response } from 'supertest';
export function expectSuccessEnvelope(response: Response): void {
expect(response.body).toMatchObject({
success: true,
trackingCode: expect.any(String),
provider: expect.any(String),
duration: expect.any(Number),
error: null,
});
}
export function expectErrorEnvelope(response: Response, code: string): void {
expect(response.body).toMatchObject({
success: false,
trackingCode: expect.any(String),
error: {
code,
message: expect.any(String),
messageFa: expect.any(String),
},
});
}

63
test/support/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { AuthenticatedUser } from '../../src/auth/interfaces/authenticated-user.interface';
import { ClientType } from '../../src/common/enums/client-type.enum';
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
import { Role } from '../../src/common/enums/role.enum';
import { AppException } from '../../src/common/exceptions/app-exception';
export function makeAuthenticatedUser(
overrides: Partial<AuthenticatedUser> = {},
): AuthenticatedUser {
return {
id: 'user-1',
username: 'admin',
email: 'admin@example.com',
role: Role.SUPER_ADMIN,
clientType: ClientType.INTERNAL,
appName: 'test-admin',
allowedInquiries: Object.values(InquiryType),
requestLimitPerMinute: 100,
requestLimitPerDay: 10000,
...overrides,
};
}
export function authenticatedGuard(user = makeAuthenticatedUser()): CanActivate {
return {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: AuthenticatedUser }>();
request.user = user;
return true;
},
};
}
export function bearerTokenGuard(user = makeAuthenticatedUser()): CanActivate {
return {
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<{ headers: Record<string, string | undefined>; user?: AuthenticatedUser }>();
const authorization = request.headers.authorization;
if (!authorization) {
throw new AppException('UNAUTHORIZED');
}
if (authorization === 'Bearer expired') {
throw new AppException('UNAUTHORIZED', { message: 'jwt expired' });
}
request.user = user;
return true;
},
};
}
export function passGuard(): CanActivate {
return {
canActivate(): boolean {
return true;
},
};
}

View File

@@ -0,0 +1,38 @@
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
export interface TestClientConfig {
clientId: string;
apiKey: string;
providerBaseUrl: string;
credentials: Record<string, string>;
requiresVpn: boolean;
allowedInquiries: InquiryType[];
}
export function createClientConfig(
overrides: Partial<TestClientConfig> = {},
): TestClientConfig {
return {
clientId: 'default-client',
apiKey: 'test-api-key',
providerBaseUrl: 'https://provider.test',
credentials: {
username: 'provider-user',
password: 'provider-password',
},
requiresVpn: false,
allowedInquiries: Object.values(InquiryType),
...overrides,
};
}
export function resolveClientConfig(
clients: TestClientConfig[],
clientId: string,
): TestClientConfig {
const client = clients.find((candidate) => candidate.clientId === clientId);
if (!client) {
throw new Error(`No test client config found for ${clientId}`);
}
return client;
}

View File

@@ -0,0 +1,79 @@
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
export interface InquiryEndpointCase {
name: string;
inquiryType: InquiryType;
path: string;
validPayload: Record<string, unknown>;
invalidPayload: Record<string, unknown>;
}
export const inquiryEndpointCases: InquiryEndpointCase[] = [
{
name: 'person inquiry',
inquiryType: InquiryType.PERSON,
path: '/inquiry/person',
validPayload: { nationalCode: '0012345678', birthDate: '1378-11-24', dateHasPostfix: 0 },
invalidPayload: { nationalCode: '123', birthDate: 'bad-date' },
},
{
name: 'real estate inquiry',
inquiryType: InquiryType.REAL_ESTATE,
path: '/inquiry/realEstate',
validPayload: { nationalCode: '5098961130', postalCode: '1349689554' },
invalidPayload: { nationalCode: 'bad', postalCode: '134' },
},
{
name: 'sheba inquiry',
inquiryType: InquiryType.SHEBA,
path: '/inquiry/sheba',
validPayload: {
accountOwnerType: '1',
nationalCode: '4311402422',
legalId: '',
sheba: 'IR800560611828005105117001',
},
invalidPayload: { nationalCode: '4311402422', sheba: 'bad' },
},
{
name: 'shahkar inquiry',
inquiryType: InquiryType.SHAHKAR,
path: '/inquiry/shahkar',
validPayload: { nationalCode: '4311402422', mobileNo: '09226187419' },
invalidPayload: { nationalCode: '4311402422', mobileNo: '123' },
},
{
name: 'postal code inquiry',
inquiryType: InquiryType.POSTAL_CODE,
path: '/inquiry/postalCode',
validPayload: { postalCode: '1234567890' },
invalidPayload: { postalCode: '123' },
},
{
name: 'policy by chassis inquiry',
inquiryType: InquiryType.POLICY_BY_CHASSIS,
path: '/inquiry/policyByChassis',
validPayload: { chassisNo: 'NAAM01E15HK123456' },
invalidPayload: { chassisNo: '' },
},
{
name: 'policy by plate inquiry',
inquiryType: InquiryType.POLICY_BY_PLATE,
path: '/inquiry/policyByPlate',
validPayload: {
nationalCode: '4311402422',
plk1: '12',
plk2: 'ب',
plk3: '345',
plksrl: '67',
},
invalidPayload: { nationalCode: 'bad', plk1: '1', plk2: '', plk3: '3', plksrl: '' },
},
{
name: 'policy by national code inquiry',
inquiryType: InquiryType.POLICY_BY_NATIONAL_CODE,
path: '/inquiry/policyByNationalCode',
validPayload: { nationalCode: '4311402422' },
invalidPayload: { nationalCode: 'bad' },
},
];

View File

@@ -0,0 +1,60 @@
import { NormalizedErrorDto } from '../../src/common/dto/normalized-error.dto';
import { ProviderName } from '../../src/common/enums/provider-name.enum';
import { buildNormalizedError } from '../../src/common/constants/error-messages';
export function successfulProviderResult(data: unknown = { raw: { ok: true } }) {
return {
data,
provider: ProviderName.HAMTA,
duration: 12,
};
}
export function normalizedProviderError(error: NormalizedErrorDto): Error {
const err = new Error(error.message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = error;
return err;
}
export const providerErrorCases = [
{
name: 'provider timeout',
code: 'PROVIDER_TIMEOUT',
error: buildNormalizedError('PROVIDER_TIMEOUT', {
message: 'Provider request timed out',
providerCode: 'ETIMEDOUT',
}),
},
{
name: 'provider HTTP 4xx',
code: 'PROVIDER_BAD_RESPONSE',
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
message: 'Provider returned HTTP 400',
providerCode: '400',
}),
},
{
name: 'provider HTTP 5xx',
code: 'PROVIDER_BAD_RESPONSE',
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
message: 'Provider returned HTTP 500',
providerCode: '500',
}),
},
{
name: 'provider business error',
code: 'INQUIRY_NO_MATCH',
error: buildNormalizedError('INQUIRY_NO_MATCH', {
message: 'Inquiry returned no matching result',
providerCode: 'NO_MATCH',
}),
},
{
name: 'malformed provider response',
code: 'PROVIDER_BAD_RESPONSE',
error: buildNormalizedError('PROVIDER_BAD_RESPONSE', {
message: 'Provider response could not be parsed',
providerCode: 'MALFORMED_RESPONSE',
}),
},
];

View File

@@ -0,0 +1,145 @@
import { maskPayload } from '../../src/common/helpers/mask-payload.helper';
import { buildValidationErrorResponse } from '../../src/common/helpers/validation-error.helper';
import {
getShahkarProviderError,
isShahkarSuccess,
normalizeShahkarFields,
parseShahkarInqueryResult,
} from '../../src/common/helpers/shahkar-response.helper';
import { getSayahProviderError } from '../../src/common/helpers/sayah-response.helper';
import { getCentInsurProviderError } from '../../src/common/helpers/centinsur-response.helper';
import { InquiryType } from '../../src/common/enums/inquiry-type.enum';
import { ClientType } from '../../src/common/enums/client-type.enum';
import { Role } from '../../src/common/enums/role.enum';
import { UserMapper } from '../../src/users/mappers/user.mapper';
import {
createClientConfig,
resolveClientConfig,
} from '../support/client-config.factory';
describe('unit helpers and mappers', () => {
it('masks sensitive payload fields recursively without mutating safe fields', () => {
expect(
maskPayload({
nationalCode: '0012345678',
nested: { password: 'secret', safe: 'visible' },
amount: 100,
}),
).toEqual({
nationalCode: '***MASKED***',
nested: { password: '***MASKED***', safe: 'visible' },
amount: 100,
});
});
it('maps Shahkar provider no-match responses to a stable internal code', () => {
const fields = {
response: '600',
result: 'NotIdentifiedException',
comment: '',
requestId: 'provider-tracking',
};
expect(isShahkarSuccess(fields)).toBe(false);
expect(getShahkarProviderError(fields)).toMatchObject({
code: 'INQUIRY_NO_MATCH',
providerTrackingCode: 'provider-tracking',
});
});
it('parses SOAP Shahkar response fields', () => {
const fields = normalizeShahkarFields(parseShahkarInqueryResult(`
<ShahkarInqueryResult>
<Response>200</Response>
<Result>OK: matched</Result>
</ShahkarInqueryResult>
`) as Record<string, unknown>);
expect(fields).toMatchObject({ response: '200', result: 'OK: matched' });
expect(isShahkarSuccess(fields)).toBe(true);
});
it('maps Sayah and CentInsur business errors', () => {
expect(
getSayahProviderError({
ReturnValue: false,
HasError: false,
Errors: [{ Code: 'SHEBA', Message: 'Mismatch' }],
}),
).toMatchObject({ code: 'SHEBA_MISMATCH' });
expect(
getCentInsurProviderError(
{ IsSucceed: true, Result: { Result: false, ErrorMessage: null } },
InquiryType.REAL_ESTATE,
),
).toMatchObject({
code: 'INQUIRY_NO_MATCH',
message: 'No property ownership found for the provided national code and postal code',
});
});
it('maps user documents without leaking secrets', () => {
const dto = UserMapper.toResponseDto({
_id: { toString: () => 'user-1' },
fullName: 'Admin User',
username: 'admin',
email: 'admin@example.com',
role: Role.SUPER_ADMIN,
clientType: ClientType.INTERNAL,
appName: 'gateway',
clientName: 'Gateway',
isActive: true,
isBlocked: false,
allowedInquiries: [],
requestLimitPerMinute: 10,
requestLimitPerDay: 100,
totalRequests: 3,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-02T00:00:00Z'),
password: 'should-not-leak',
} as never);
expect(dto).toMatchObject({ id: 'user-1', username: 'admin' });
expect(dto).not.toHaveProperty('password');
});
it('resolves client-specific credentials and network configuration', () => {
const vpnClient = createClientConfig({
clientId: 'vpn-client',
requiresVpn: true,
providerBaseUrl: 'https://vpn-only.provider.test',
credentials: { username: 'vpn-user', password: 'vpn-password' },
});
expect(resolveClientConfig([createClientConfig(), vpnClient], 'vpn-client')).toMatchObject({
providerBaseUrl: 'https://vpn-only.provider.test',
credentials: { username: 'vpn-user', password: 'vpn-password' },
requiresVpn: true,
});
});
it('returns one prioritized validation error instead of duplicate constraints', () => {
const { normalizedError } = buildValidationErrorResponse([
{
property: 'nationalCode',
constraints: {
isLength: 'nationalCode must be longer than or equal to 10 characters',
matches: 'nationalCode must be exactly 10 digits',
},
},
] as never);
expect(normalizedError).toMatchObject({
code: 'VALIDATION_ERROR',
message: 'nationalCode must be exactly 10 digits',
messageFa: 'کد ملی باید دقیقا ۱۰ رقم باشد',
details: [
{
field: 'nationalCode',
constraints: ['nationalCode must be exactly 10 digits'],
},
],
});
});
});