forked from Shared/esg
Compare commits
28 Commits
0aad5a34d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e0870b2f1 | |||
| e474f2d52d | |||
| 5ace0165db | |||
| 9fa4144949 | |||
|
|
8af8c8d6df | ||
| 73966878a2 | |||
|
|
e7bb312db2 | ||
| 62c8c6a3b4 | |||
| 91f43c312f | |||
| 45ef396466 | |||
| 4a65f356df | |||
| 63d41f4288 | |||
| 9ca6b61ca6 | |||
| e17181f22e | |||
| 7bdba4f3cc | |||
| b3adb6f583 | |||
| d675ba220a | |||
| 539e7a4060 | |||
| 873fb1a1e2 | |||
| 9467b18467 | |||
| 1b7f678538 | |||
| fac6142483 | |||
| 918aed3b34 | |||
| 2212f6da41 | |||
| 7a6e482586 | |||
| b629a47f7f | |||
| 2cb3340f40 | |||
| 13d6e0d1ea |
22
.env.example
22
.env.example
@@ -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,10 +21,17 @@ 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_DEFAULT_PROVIDER=PARSIAN
|
||||||
SHAHKAR_FALLBACK_ENABLED=false
|
SHAHKAR_FALLBACK_ENABLED=false
|
||||||
SHAHKAR_FALLBACK_PROVIDERS=
|
SHAHKAR_FALLBACK_PROVIDERS=
|
||||||
@@ -49,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
|
||||||
@@ -63,6 +77,10 @@ MOALLEM_MAX_RETRIES=2
|
|||||||
PARSIAN_ENABLED=true
|
PARSIAN_ENABLED=true
|
||||||
PARSIAN_TIMEOUT=10000
|
PARSIAN_TIMEOUT=10000
|
||||||
PARSIAN_MAX_RETRIES=2
|
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_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
|
||||||
PARSIAN_SHAHKAR_API_KEY=
|
PARSIAN_SHAHKAR_API_KEY=
|
||||||
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
|
PARSIAN_SHAHKAR_AUTH_METHOD=NONE
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -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,7 +131,7 @@ 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` | PARSIAN, HAMTA, MOALLEM | Mobile number verification |
|
| `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -203,6 +203,12 @@ PARSIAN_ENABLED=true
|
|||||||
PARSIAN_TIMEOUT=10000
|
PARSIAN_TIMEOUT=10000
|
||||||
PARSIAN_MAX_RETRIES=2
|
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)
|
# Shahkar Inquiry (X-PACKAGE-API-KEY header)
|
||||||
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
|
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
|
||||||
PARSIAN_SHAHKAR_API_KEY=your-package-api-key
|
PARSIAN_SHAHKAR_API_KEY=your-package-api-key
|
||||||
@@ -277,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
|
||||||
@@ -497,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
36
jest.config.js
Normal 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
213
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +29,7 @@ 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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
374
src/common/constants/error-messages.ts
Normal file
374
src/common/constants/error-messages.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/common/exceptions/app-exception.spec.ts
Normal file
94
src/common/exceptions/app-exception.spec.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/common/exceptions/app-exception.ts
Normal file
34
src/common/exceptions/app-exception.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/common/helpers/centinsur-response.helper.ts
Normal file
72
src/common/helpers/centinsur-response.helper.ts
Normal 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(' | ');
|
||||||
|
}
|
||||||
222
src/common/helpers/http-client.helper.ts
Normal file
222
src/common/helpers/http-client.helper.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
36
src/common/helpers/inquiry-response.helper.ts
Normal file
36
src/common/helpers/inquiry-response.helper.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
70
src/common/helpers/sayah-response.helper.ts
Normal file
70
src/common/helpers/sayah-response.helper.ts
Normal 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;
|
||||||
|
}
|
||||||
124
src/common/helpers/shahkar-response.helper.ts
Normal file
124
src/common/helpers/shahkar-response.helper.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
function decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/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(' | ');
|
||||||
|
}
|
||||||
85
src/common/helpers/soap-car-policy.helper.ts
Normal file
85
src/common/helpers/soap-car-policy.helper.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
function decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/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;
|
||||||
|
}
|
||||||
92
src/common/helpers/soap-civil-registration.helper.ts
Normal file
92
src/common/helpers/soap-civil-registration.helper.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
function decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/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;
|
||||||
|
}
|
||||||
60
src/common/helpers/translate-error.helper.ts
Normal file
60
src/common/helpers/translate-error.helper.ts
Normal 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;
|
||||||
|
}
|
||||||
213
src/common/helpers/validation-error.helper.ts
Normal file
213
src/common/helpers/validation-error.helper.ts
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ 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,
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
export class PolicyByPlateRequestDto {
|
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' })
|
@ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })
|
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })
|
||||||
|
|||||||
18
src/inquiry/dto/real-estate-request.dto.ts
Normal file
18
src/inquiry/dto/real-estate-request.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -16,9 +16,23 @@ export class SayahRequestDto {
|
|||||||
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
|
||||||
nationalCode!: 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({
|
@ApiProperty({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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 { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
|
||||||
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
|
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
|
||||||
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto';
|
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto';
|
||||||
@@ -68,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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
29
src/main.ts
29
src/main.ts
@@ -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',
|
||||||
|
|||||||
@@ -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(/&/g, '&').replace(/</g, '<').replace(/>/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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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) {
|
||||||
@@ -232,6 +260,7 @@ export class HamtaProvider extends LegacyApiProvider {
|
|||||||
const shebaId = this.getRequiredString(payload.sheba, 'sheba');
|
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 {
|
||||||
@@ -346,15 +393,6 @@ export class HamtaProvider extends LegacyApiProvider {
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeXml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/&/g, '&');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Made with Bob
|
// Made with Bob
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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) {
|
||||||
@@ -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, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
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 {
|
||||||
|
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 { 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';
|
||||||
@@ -10,8 +21,17 @@ import { AmitisProvider } from './amitis.provider';
|
|||||||
import {
|
import {
|
||||||
LegacyInquiryPayload,
|
LegacyInquiryPayload,
|
||||||
LegacyInquiryResult,
|
LegacyInquiryResult,
|
||||||
|
PersonInquiryResult,
|
||||||
} from '../shared/legacy-api.provider.abstract';
|
} from '../shared/legacy-api.provider.abstract';
|
||||||
|
|
||||||
|
interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload {
|
||||||
|
nationalCode?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
NIN?: string;
|
||||||
|
BirthDate?: string;
|
||||||
|
dateHasPostfix?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ParsianShahkarPayload extends LegacyInquiryPayload {
|
interface ParsianShahkarPayload extends LegacyInquiryPayload {
|
||||||
nationalCode?: string;
|
nationalCode?: string;
|
||||||
nationalCod?: string;
|
nationalCod?: string;
|
||||||
@@ -28,7 +48,7 @@ interface ParsianShahkarResponse {
|
|||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
response?: number;
|
response?: number | string;
|
||||||
result?: string | null;
|
result?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +81,7 @@ interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
|
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
|
||||||
|
nationalCode?: string;
|
||||||
plk1?: string;
|
plk1?: string;
|
||||||
plk2?: string;
|
plk2?: string;
|
||||||
plk3?: string;
|
plk3?: string;
|
||||||
@@ -71,6 +92,7 @@ interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
|
|||||||
export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyInquiryResult> {
|
export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyInquiryResult> {
|
||||||
readonly name = ProviderName.PARSIAN;
|
readonly name = ProviderName.PARSIAN;
|
||||||
readonly supportedInquiryTypes = [
|
readonly supportedInquiryTypes = [
|
||||||
|
InquiryType.PERSON,
|
||||||
InquiryType.SHAHKAR,
|
InquiryType.SHAHKAR,
|
||||||
InquiryType.SHEBA,
|
InquiryType.SHEBA,
|
||||||
InquiryType.POLICY_BY_CHASSIS,
|
InquiryType.POLICY_BY_CHASSIS,
|
||||||
@@ -86,6 +108,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(): boolean {
|
isEnabled(): boolean {
|
||||||
|
const personConfig = this.config.inquiries[InquiryType.PERSON];
|
||||||
const shahkarConfig = this.config.inquiries[InquiryType.SHAHKAR];
|
const shahkarConfig = this.config.inquiries[InquiryType.SHAHKAR];
|
||||||
const shebaConfig = this.config.inquiries[InquiryType.SHEBA];
|
const shebaConfig = this.config.inquiries[InquiryType.SHEBA];
|
||||||
const policyByChassisConfig = this.config.inquiries[InquiryType.POLICY_BY_CHASSIS];
|
const policyByChassisConfig = this.config.inquiries[InquiryType.POLICY_BY_CHASSIS];
|
||||||
@@ -95,7 +118,8 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
this.config.enabled &&
|
this.config.enabled &&
|
||||||
((Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
|
(this.hasSoapCredentials(personConfig) ||
|
||||||
|
(Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
|
||||||
(Boolean(shebaConfig?.url) &&
|
(Boolean(shebaConfig?.url) &&
|
||||||
Boolean(shebaConfig?.username) &&
|
Boolean(shebaConfig?.username) &&
|
||||||
Boolean(shebaConfig?.password)) ||
|
Boolean(shebaConfig?.password)) ||
|
||||||
@@ -110,6 +134,10 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
payload: LegacyInquiryPayload,
|
payload: LegacyInquiryPayload,
|
||||||
_context: ProviderExecutionContext,
|
_context: ProviderExecutionContext,
|
||||||
): Promise<LegacyInquiryResult> {
|
): Promise<LegacyInquiryResult> {
|
||||||
|
if (inquiryType === InquiryType.PERSON) {
|
||||||
|
return this.inquireCivilRegistration(payload as ParsianCivilRegistrationPayload);
|
||||||
|
}
|
||||||
|
|
||||||
if (inquiryType === InquiryType.SHAHKAR) {
|
if (inquiryType === InquiryType.SHAHKAR) {
|
||||||
return this.inquireShahkar(payload as ParsianShahkarPayload);
|
return this.inquireShahkar(payload as ParsianShahkarPayload);
|
||||||
}
|
}
|
||||||
@@ -157,18 +185,35 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<ParsianShahkarResponse>(inquiryConfig.url, {
|
const response = await axios.get<ParsianShahkarResponse>(
|
||||||
params: {
|
inquiryConfig.url,
|
||||||
nationalCode,
|
mergeOutboundAxiosConfig({
|
||||||
mobileNumber,
|
params: {
|
||||||
},
|
nationalCode,
|
||||||
headers: {
|
mobileNumber,
|
||||||
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
|
},
|
||||||
},
|
headers: {
|
||||||
timeout: this.config.timeout,
|
Accept: 'application/json',
|
||||||
});
|
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
|
||||||
|
},
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const body = response.data;
|
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 {
|
return {
|
||||||
raw: {
|
raw: {
|
||||||
nationalCode,
|
nationalCode,
|
||||||
@@ -188,20 +233,83 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private async inquirePolicyByChassis(
|
||||||
payload: ParsianPolicyByChassisPayload,
|
payload: ParsianPolicyByChassisPayload,
|
||||||
): Promise<{ raw: unknown }> {
|
): Promise<{ raw: unknown }> {
|
||||||
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo');
|
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo');
|
||||||
const response = await this.callCarPolicySoap(
|
const policy = await this.callCarPolicySoap(
|
||||||
InquiryType.POLICY_BY_CHASSIS,
|
InquiryType.POLICY_BY_CHASSIS,
|
||||||
'CIIWSPolicyChassis',
|
'CIIWSPolicyChassis',
|
||||||
{ Chassisno: chassisNo },
|
{ ChassisNo: chassisNo },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: {
|
raw: {
|
||||||
chassisNo,
|
chassisNo,
|
||||||
...response,
|
...policy,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -210,16 +318,16 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
payload: ParsianPolicyByNationalCodePayload,
|
payload: ParsianPolicyByNationalCodePayload,
|
||||||
): Promise<{ raw: unknown }> {
|
): Promise<{ raw: unknown }> {
|
||||||
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
|
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
|
||||||
const response = await this.callCarPolicySoap(
|
const policy = await this.callCarPolicySoap(
|
||||||
InquiryType.POLICY_BY_NATIONAL_CODE,
|
InquiryType.POLICY_BY_NATIONAL_CODE,
|
||||||
'CIIWSPolicyNationalId',
|
'CIIWSPolicyNationalId',
|
||||||
{ nationalId: nationalCode },
|
{ NationalId: nationalCode },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: {
|
raw: {
|
||||||
nationalCode,
|
nationalCode,
|
||||||
...response,
|
...policy,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -227,34 +335,53 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
private async inquirePolicyByPlate(
|
private async inquirePolicyByPlate(
|
||||||
payload: ParsianPolicyByPlatePayload,
|
payload: ParsianPolicyByPlatePayload,
|
||||||
): Promise<{ raw: unknown }> {
|
): Promise<{ raw: unknown }> {
|
||||||
|
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
|
||||||
const plk1 = this.getRequiredString(payload.plk1, 'plk1');
|
const plk1 = this.getRequiredString(payload.plk1, 'plk1');
|
||||||
const plk2 = this.getRequiredString(payload.plk2, 'plk2');
|
const plk2 = this.getRequiredString(payload.plk2, 'plk2');
|
||||||
const plk3 = this.getRequiredString(payload.plk3, 'plk3');
|
const plk3 = this.getRequiredString(payload.plk3, 'plk3');
|
||||||
const plksrl = this.getRequiredString(payload.plksrl, 'plksrl');
|
const plksrl = this.getRequiredString(payload.plksrl, 'plksrl');
|
||||||
const response = await this.callCarPolicySoap(
|
const policy = await this.callCarPolicySoap(
|
||||||
InquiryType.POLICY_BY_PLATE,
|
InquiryType.POLICY_BY_PLATE,
|
||||||
'CIIWSPolicyVehicleMeli',
|
'CIIWSPolicyVehicleMeli',
|
||||||
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, Plksrl: plksrl },
|
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
|
||||||
'PassWord',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const policyNationalId = policy.NtnlId?.trim() ?? '';
|
||||||
|
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
|
||||||
|
throw this.formatProviderError(
|
||||||
|
'عدم تطابق اطلاعات',
|
||||||
|
'INQUIRY_NO_MATCH',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
conflict: {
|
||||||
|
nationalCode,
|
||||||
|
NtnlId: policyNationalId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: {
|
raw: {
|
||||||
|
nationalCode,
|
||||||
plk1,
|
plk1,
|
||||||
plk2,
|
plk2,
|
||||||
plk3,
|
plk3,
|
||||||
plksrl,
|
plksrl,
|
||||||
...response,
|
...policy,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nationalIdsMatch(requested: string, fromPolicy: string): boolean {
|
||||||
|
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
|
||||||
|
}
|
||||||
|
|
||||||
private async callCarPolicySoap(
|
private async callCarPolicySoap(
|
||||||
inquiryType: InquiryType,
|
inquiryType: InquiryType,
|
||||||
methodName: string,
|
methodName: string,
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
passwordFieldName = 'Password',
|
): Promise<Record<string, string>> {
|
||||||
): Promise<{ result: string | null; soap: string }> {
|
|
||||||
const inquiryConfig = this.config.inquiries[inquiryType];
|
const inquiryConfig = this.config.inquiries[inquiryType];
|
||||||
|
|
||||||
if (!this.hasSoapCredentials(inquiryConfig)) {
|
if (!this.hasSoapCredentials(inquiryConfig)) {
|
||||||
@@ -273,22 +400,24 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
fields,
|
fields,
|
||||||
inquiryConfig.username,
|
inquiryConfig.username,
|
||||||
inquiryConfig.password,
|
inquiryConfig.password,
|
||||||
passwordFieldName,
|
|
||||||
),
|
),
|
||||||
{
|
mergeOutboundAxiosConfig({
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/xml; charset=utf-8',
|
'Content-Type': 'text/xml; charset=utf-8',
|
||||||
SOAPAction: `"http://tempuri.org/ICarAllPlcysV4/${methodName}"`,
|
SOAPAction: `"http://tempuri.org/ICarAllPlcys/${methodName}"`,
|
||||||
},
|
},
|
||||||
timeout: this.config.timeout,
|
timeout: this.config.timeout,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const policy = parseFirstCarPolicy(response.data);
|
||||||
result: this.extractSoapValue(response.data, `${methodName}Result`),
|
const providerError = getCarPolicyProviderError(response.data, policy);
|
||||||
soap: response.data,
|
if (providerError) {
|
||||||
};
|
throw this.formatProviderError(providerError.message, providerError.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return policy;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
throw this.formatProviderError(
|
throw this.formatProviderError(
|
||||||
@@ -323,7 +452,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<ParsianSayahResponse>(
|
const response = await axios.post<SayahApiResponse>(
|
||||||
inquiryConfig.url,
|
inquiryConfig.url,
|
||||||
{
|
{
|
||||||
AccountOwnerType: accountOwnerType,
|
AccountOwnerType: accountOwnerType,
|
||||||
@@ -331,23 +460,18 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
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.config.timeout,
|
timeout: this.config.timeout,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
const providerError = getSayahProviderError(response.data);
|
||||||
(response.data.IsSucceed === false || response.data.isSucceed === false) &&
|
if (providerError) {
|
||||||
this.hasErrors(response.data.Errors ?? response.data.errors)
|
throw this.formatProviderError(providerError.message, providerError.code);
|
||||||
) {
|
|
||||||
throw this.formatProviderError(
|
|
||||||
this.formatErrors(response.data.Errors ?? response.data.errors),
|
|
||||||
'SAYAH_HAS_ERROR',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { raw: response.data };
|
return { raw: response.data };
|
||||||
@@ -381,7 +505,6 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
passwordFieldName: string,
|
|
||||||
): string {
|
): string {
|
||||||
const fieldXml = Object.entries(fields)
|
const fieldXml = Object.entries(fields)
|
||||||
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
|
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
|
||||||
@@ -394,18 +517,52 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
|
|||||||
<soap:Body>
|
<soap:Body>
|
||||||
<${methodName} xmlns="http://tempuri.org/">
|
<${methodName} xmlns="http://tempuri.org/">
|
||||||
${fieldXml}
|
${fieldXml}
|
||||||
<UserName>${this.escapeXml(username)}</UserName>
|
<Username>${this.escapeXml(username)}</Username>
|
||||||
<${passwordFieldName}>${this.escapeXml(password)}</${passwordFieldName}>
|
<PassWrod>${this.escapeXml(password)}</PassWrod>
|
||||||
</${methodName}>
|
</${methodName}>
|
||||||
</soap:Body>
|
</soap:Body>
|
||||||
</soap:Envelope>`;
|
</soap:Envelope>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractSoapValue(xml: string, tagName: string): string | null {
|
private buildCivilRegistrationEnvelope(
|
||||||
const match = xml.match(
|
payload: ParsianCivilRegistrationPayload,
|
||||||
new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`),
|
nationalCode: string,
|
||||||
);
|
birthDate: string,
|
||||||
return match ? this.decodeXml(match[1].trim()) : null;
|
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 {
|
private escapeXml(value: string): string {
|
||||||
@@ -417,15 +574,6 @@ ${fieldXml}
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeXml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/&/g, '&');
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasErrors(
|
private hasErrors(
|
||||||
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
|
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
221
test/admin/admin.e2e-spec.ts
Normal file
221
test/admin/admin.e2e-spec.ts
Normal 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');
|
||||||
|
});
|
||||||
131
test/integration/inquiry.e2e-spec.ts
Normal file
131
test/integration/inquiry.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
78
test/smoke/providers.smoke-spec.ts
Normal file
78
test/smoke/providers.smoke-spec.ts
Normal 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
40
test/support/app.ts
Normal 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;
|
||||||
|
}
|
||||||
23
test/support/assertions.ts
Normal file
23
test/support/assertions.ts
Normal 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
63
test/support/auth.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
38
test/support/client-config.factory.ts
Normal file
38
test/support/client-config.factory.ts
Normal 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;
|
||||||
|
}
|
||||||
79
test/support/inquiry-endpoints.ts
Normal file
79
test/support/inquiry-endpoints.ts
Normal 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' },
|
||||||
|
},
|
||||||
|
];
|
||||||
60
test/support/mock-provider.ts
Normal file
60
test/support/mock-provider.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
145
test/unit/helpers-and-mappers.spec.ts
Normal file
145
test/unit/helpers-and-mappers.spec.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user