Compare commits

...

28 Commits

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

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

View File

@@ -1,6 +1,9 @@
# Application
PORT=8085
NODE_ENV=development
ESG_SMOKE_ENABLED=false
CLIENT_URL=https://apex.mic.co.ir/esg
BASE_URL_PROD=
# MongoDB
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
@@ -18,10 +21,17 @@ BCRYPT_SALT_ROUNDS=12
THROTTLE_TTL=60
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)
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=MOALLEM,TEJARATNOU
PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
SHAHKAR_DEFAULT_PROVIDER=PARSIAN
SHAHKAR_FALLBACK_ENABLED=false
SHAHKAR_FALLBACK_PROVIDERS=
@@ -49,6 +59,10 @@ HAMTA_SECRET_KEY=
HAMTA_TIMEOUT=10000
HAMTA_ENABLED=true
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_BASE_URL=https://api.moallem.example.com
@@ -63,6 +77,10 @@ MOALLEM_MAX_RETRIES=2
PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=
PARSIAN_PERSON_PASSWORD=
PARSIAN_PERSON_AUTH_METHOD=SOAP
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=
PARSIAN_SHAHKAR_AUTH_METHOD=NONE

View File

@@ -111,14 +111,14 @@ Per-inquiry configuration via environment variables:
```env
# Default provider and fallback chain
PERSON_DEFAULT_PROVIDER=HAMTA
PERSON_FALLBACK_PROVIDERS=MOALLEM
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
# Per-inquiry credentials and authentication
HAMTA_PERSON_URL=https://api.example.com/person
HAMTA_PERSON_USERNAME=user123
HAMTA_PERSON_PASSWORD=pass123
HAMTA_PERSON_AUTH_METHOD=AMITIS
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=user123
PARSIAN_PERSON_PASSWORD=pass123
PARSIAN_PERSON_AUTH_METHOD=SOAP
# AMITIS authentication service
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 |
|--------------|-----------|-------------|
| `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 |
| `POSTAL_CODE_INQUIRY` | HAMTA, MOALLEM | Address validation by postal code |
| `SHAHKAR_INQUIRY` | PARSIAN, HAMTA, MOALLEM | Mobile number verification |

View File

@@ -112,11 +112,11 @@ HAMTA_ENABLED=true
HAMTA_TIMEOUT=10000
HAMTA_MAX_RETRIES=3
# Person Inquiry (AMITIS auth)
HAMTA_PERSON_URL=https://api.hamta.example.com/api/inquiry/person
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
# Person Inquiry (SOAP auth)
HAMTA_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
HAMTA_PERSON_USERNAME=hamta.sabtahval
HAMTA_PERSON_PASSWORD=your-password
HAMTA_PERSON_AUTH_METHOD=SOAP
# Real Estate Inquiry (AMITIS auth)
HAMTA_REAL_ESTATE_URL=https://apigw.services.centinsur.ir/amlakeskanservice/amlakeskan/inquiry
@@ -203,6 +203,12 @@ PARSIAN_ENABLED=true
PARSIAN_TIMEOUT=10000
PARSIAN_MAX_RETRIES=2
# Person Inquiry (SOAP auth)
PARSIAN_PERSON_URL=http://reinsure.centinsur.ir/SabtV3Out
PARSIAN_PERSON_USERNAME=pa6476
PARSIAN_PERSON_PASSWORD=your-password
PARSIAN_PERSON_AUTH_METHOD=SOAP
# Shahkar Inquiry (X-PACKAGE-API-KEY header)
PARSIAN_SHAHKAR_URL=https://apigateway.parsianinsurance.com/shahkarinqOut
PARSIAN_SHAHKAR_API_KEY=your-package-api-key
@@ -277,22 +283,22 @@ Routing determines which provider handles each inquiry type and whether to use f
#### Person Inquiry (Single Provider)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=false
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)
```env
PERSON_DEFAULT_PROVIDER=TEJARATNOU
PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true
PERSON_FALLBACK_PROVIDERS=HAMTA,MOALLEM
PERSON_FALLBACK_PROVIDERS=HAMTA,TEJARATNOU
```
**Behavior**:
1. Try TEJARATNOU first
1. Try PARSIAN first
2. If fails, try HAMTA
3. If fails, try MOALLEM
3. If fails, try TEJARATNOU
4. If all fail, return error
#### Real Estate Inquiry
@@ -497,9 +503,9 @@ AMITIS_HAMTA_PERSON_PASSWORD=ciiws@sabt92
### New Format (Current)
```env
HAMTA_PERSON_USERNAME=pa6476
HAMTA_PERSON_PASSWORD=ciiws@sabt92
HAMTA_PERSON_AUTH_METHOD=AMITIS
PARSIAN_PERSON_USERNAME=pa6476
PARSIAN_PERSON_PASSWORD=your-password
PARSIAN_PERSON_AUTH_METHOD=SOAP
```
**Key Changes:**

36
jest.config.js Normal file
View File

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

View File

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

213
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3"
},
"devDependencies": {
@@ -52,12 +53,14 @@
"@types/jest": "^29.5.14",
"@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
@@ -2371,6 +2374,19 @@
"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": {
"version": "0.3.2",
"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_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": {
"version": "0.11.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4065,6 +4091,13 @@
"@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": {
"version": "9.6.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz",
@@ -4200,6 +4233,13 @@
"@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": {
"version": "2.1.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz",
@@ -4327,6 +4367,30 @@
"dev": true,
"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": {
"version": "4.0.14",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz",
@@ -5060,6 +5124,13 @@
"dev": true,
"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": {
"version": "0.4.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz",
@@ -5849,6 +5920,16 @@
"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": {
"version": "0.0.1",
"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_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": {
"version": "1.0.3",
"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_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": {
"version": "4.0.4",
"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_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": {
"version": "0.2.0",
"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_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": {
"version": "1.9.1",
"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_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": {
"version": "0.7.4",
"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"
}
},
"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": {
"version": "7.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,9 +10,14 @@
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test": "jest --selectProjects unit integration admin",
"test:unit": "jest --selectProjects unit",
"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"
},
"dependencies": {
@@ -48,6 +53,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socks-proxy-agent": "^7.0.0",
"uuid": "^11.0.3"
},
"devDependencies": {
@@ -59,33 +65,18 @@
"@types/jest": "^29.5.14",
"@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { ADMIN_ROLES } from '../../common/enums/role.enum';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
import { AppException } from '../../common/exceptions/app-exception';
/**
* Validates that the user may call a specific inquiry endpoint.
@@ -28,7 +29,7 @@ export class InquiryAccessGuard implements CanActivate {
const user = request.user;
if (!user) {
throw new ForbiddenException('Authentication required');
throw new AppException('AUTHENTICATION_REQUIRED');
}
if (ADMIN_ROLES.includes(user.role)) {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
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 { AuthenticatedUser } from '../interfaces/authenticated-user.interface';
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.
@@ -27,10 +28,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY)
const user = await this.usersService.findById(payload.sub);
if (!user.isActive) {
throw new UnauthorizedException('Account is inactive');
throw new AppException('ACCOUNT_INACTIVE');
}
if (user.isBlocked) {
throw new UnauthorizedException('Account is blocked');
throw new AppException('ACCOUNT_BLOCKED');
}
return {

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { randomBytes } from 'crypto';
@@ -8,6 +8,7 @@ import { PasswordService } from '../auth/services/password.service';
import { User, UserDocument } from '../users/schemas/user.schema';
import { UserMapper } from '../users/mappers/user.mapper';
import { UserResponseDto } from '../users/dto/user-response.dto';
import { AppException } from '../common/exceptions/app-exception';
export interface CreateSuperAdminInput {
fullName: string;
@@ -27,7 +28,7 @@ export class CreateSuperAdminService {
async create(input: CreateSuperAdminInput): Promise<UserResponseDto> {
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();
@@ -35,7 +36,7 @@ export class CreateSuperAdminService {
const existing = await this.userModel.findOne({ $or: [{ username }, { email }] });
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 });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,10 @@ export default () => ({
},
} satisfies TejaratNouConfig,
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, [
ProviderName.MOALLEM,
]),

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,11 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class PolicyByPlateRequestDto {
@ApiProperty({ example: '4311402422', description: 'Owner national code (10 digits)' })
@IsString()
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiProperty({ example: '12', description: 'Two digits on the left side of the plate' })
@IsString()
@Matches(/^\d{2}$/, { message: 'plk1 must be exactly 2 digits' })

View File

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

View File

@@ -16,9 +16,23 @@ export class SayahRequestDto {
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiPropertyOptional({ nullable: true })
@ApiPropertyOptional({
type: String,
example: '',
nullable: true,
description: 'Legal entity ID; use empty string for natural persons',
})
@IsOptional()
@IsString()
@Transform(({ value }: { value: unknown }) => {
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'object') {
return '';
}
return String(value);
})
legalId?: string | null;
@ApiProperty({

View File

@@ -28,6 +28,7 @@ import { GenericInquiryResponseDto } from './dto/generic-inquiry-response.dto';
import { PostalCodeRequestDto } from './dto/postal-code-request.dto';
import { ShahkarRequestDto } from './dto/shahkar-request.dto';
import { SayahRequestDto } from './dto/sayah-request.dto';
import { RealEstateRequestDto } from './dto/real-estate-request.dto';
import { PolicyByChassisRequestDto } from './dto/policy-by-chassis-request.dto';
import { PolicyByNationalCodeRequestDto } from './dto/policy-by-national-code-request.dto';
import { PolicyByPlateRequestDto } from './dto/policy-by-plate-request.dto';
@@ -68,10 +69,14 @@ export class InquiryController {
@ApiOperation({ summary: 'Real estate inquiry' })
@ApiResponse({ status: 200, type: GenericInquiryResponseDto })
async inquireRealEstate(
@Body() payload: Record<string, unknown>,
@Body() payload: RealEstateRequestDto,
@Req() req: Request & { requestId?: string; trackingCode?: string },
): 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')

View File

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

View File

@@ -7,6 +7,8 @@ import { NextFunction, Request, Response } from 'express';
import { AppModule } from './app.module';
import { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger';
import { buildValidationErrorResponse } from './common/helpers/validation-error.helper';
import { AppException } from './common/exceptions/app-exception';
import {
recordHttpRequestEnd,
recordHttpRequestStart,
@@ -46,16 +48,39 @@ async function bootstrap(): Promise<void> {
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,
});
},
}),
);
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')
.setDescription(
'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' +
'per-user inquiry access, audit logging, and normalized inquiry responses.',
)
.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(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'bearer',

View File

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

View File

@@ -1,8 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
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 { 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 { GeneralTokenDocument } from '../schemas/general-token.schema';
import { GeneralTokenService } from '../services/general-token.service';
@@ -22,6 +31,8 @@ interface CentInsurTokenResponse {
ExpiresIn?: number | string;
expiresIn?: number | string;
expires_in?: number | string;
IsSucceed?: boolean;
LoginStatus?: number;
data?: CentInsurTokenResponse;
}
@@ -49,7 +60,7 @@ export class AmitisProvider {
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<AmitisAuthServiceConfig>('amitis')!;
this.httpClient = axios.create({
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
@@ -102,25 +113,36 @@ export class AmitisProvider {
username: string,
password: string,
): Promise<string> {
// Use FormData for multipart/form-data format
const FormData = require('form-data');
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const loginBody = new URLSearchParams({
username: username.toLowerCase(),
password,
});
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>(
this.config.loginPath,
formData,
loginBody.toString(),
{
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);
await this.saveToken(token, providerName, inquiryType, username, 'login');
await this.saveToken(token, providerName, inquiryType, username, password, 'login');
return token.accessToken;
} catch (error) {
throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error);
@@ -144,6 +166,7 @@ export class AmitisProvider {
providerName,
inquiryType,
latestToken.username,
latestToken.clientSecret,
'refresh',
);
return token.accessToken;
@@ -160,15 +183,16 @@ export class AmitisProvider {
providerName: ProviderName,
inquiryType: InquiryType,
username: string,
password: string,
scope: string,
): Promise<void> {
await this.generalTokenService.create({
serviceProvider: this.getTokenKey(providerName, inquiryType),
tokenType: token.tokenType,
url: this.config.baseUrl,
clientId: '',
clientSecret: '',
username,
clientId: username.toLowerCase(),
clientSecret: password,
username: username.toLowerCase(),
scope,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
@@ -182,11 +206,32 @@ export class AmitisProvider {
fallbackRefreshToken?: string,
): AmitisAuthToken {
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 =
body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token;
if (!accessToken) {
throw new Error('AMITIS auth response did not include an access token');
if (!accessToken || !String(accessToken).trim()) {
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);
@@ -226,17 +271,55 @@ export class AmitisProvider {
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 {
if (error instanceof Error && 'normalizedError' in error) {
return error;
}
if (error instanceof AxiosError) {
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) {
return new Error(`${message}: ${error.message}`);
return this.createProviderAuthError(`${message}: ${error.message}`, error);
}
return new Error(`${message}: ${String(error)}`);
return this.createProviderAuthError(`${message}: ${String(error)}`, error);
}
}

View File

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

View File

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

View File

@@ -1,6 +1,17 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
buildCivilRegistrationFullName,
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getCarPolicyProviderError,
parseFirstCarPolicy,
} from '../../common/helpers/soap-car-policy.helper';
import { getSayahProviderError, SayahApiResponse } from '../../common/helpers/sayah-response.helper';
import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
@@ -10,8 +21,17 @@ import { AmitisProvider } from './amitis.provider';
import {
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload {
nationalCode?: string;
birthDate?: string;
NIN?: string;
BirthDate?: string;
dateHasPostfix?: number;
}
interface ParsianShahkarPayload extends LegacyInquiryPayload {
nationalCode?: string;
nationalCod?: string;
@@ -28,7 +48,7 @@ interface ParsianShahkarResponse {
comment?: string | null;
id?: string | null;
requestId?: string | null;
response?: number;
response?: number | string;
result?: string | null;
}
@@ -61,6 +81,7 @@ interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
}
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
nationalCode?: string;
plk1?: string;
plk2?: string;
plk3?: string;
@@ -71,6 +92,7 @@ interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyInquiryResult> {
readonly name = ProviderName.PARSIAN;
readonly supportedInquiryTypes = [
InquiryType.PERSON,
InquiryType.SHAHKAR,
InquiryType.SHEBA,
InquiryType.POLICY_BY_CHASSIS,
@@ -86,6 +108,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
}
isEnabled(): boolean {
const personConfig = this.config.inquiries[InquiryType.PERSON];
const shahkarConfig = this.config.inquiries[InquiryType.SHAHKAR];
const shebaConfig = this.config.inquiries[InquiryType.SHEBA];
const policyByChassisConfig = this.config.inquiries[InquiryType.POLICY_BY_CHASSIS];
@@ -95,7 +118,8 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
return (
this.config.enabled &&
((Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
(this.hasSoapCredentials(personConfig) ||
(Boolean(shahkarConfig?.url) && Boolean(shahkarConfig?.apiKey)) ||
(Boolean(shebaConfig?.url) &&
Boolean(shebaConfig?.username) &&
Boolean(shebaConfig?.password)) ||
@@ -110,6 +134,10 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
payload: LegacyInquiryPayload,
_context: ProviderExecutionContext,
): Promise<LegacyInquiryResult> {
if (inquiryType === InquiryType.PERSON) {
return this.inquireCivilRegistration(payload as ParsianCivilRegistrationPayload);
}
if (inquiryType === InquiryType.SHAHKAR) {
return this.inquireShahkar(payload as ParsianShahkarPayload);
}
@@ -157,18 +185,35 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
}
try {
const response = await axios.get<ParsianShahkarResponse>(inquiryConfig.url, {
const response = await axios.get<ParsianShahkarResponse>(
inquiryConfig.url,
mergeOutboundAxiosConfig({
params: {
nationalCode,
mobileNumber,
},
headers: {
Accept: 'application/json',
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
},
timeout: this.config.timeout,
});
}),
);
const body = response.data;
const responseCode = Number(body.response);
if (!Number.isFinite(responseCode) || responseCode !== 200) {
throw this.formatProviderError(
body.comment ?? body.errorNams ?? body.ErrorNams ?? 'Shahkar inquiry failed',
String(body.response ?? 'PROVIDER_ERROR'),
undefined,
{
providerTrackingCode: body.requestId ?? body.id ?? undefined,
},
);
}
return {
raw: {
nationalCode,
@@ -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(
payload: ParsianPolicyByChassisPayload,
): Promise<{ raw: unknown }> {
const chassisNo = this.getRequiredString(payload.chassisNo, 'chassisNo');
const response = await this.callCarPolicySoap(
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_CHASSIS,
'CIIWSPolicyChassis',
{ Chassisno: chassisNo },
{ ChassisNo: chassisNo },
);
return {
raw: {
chassisNo,
...response,
...policy,
},
};
}
@@ -210,16 +318,16 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
payload: ParsianPolicyByNationalCodePayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const response = await this.callCarPolicySoap(
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_NATIONAL_CODE,
'CIIWSPolicyNationalId',
{ nationalId: nationalCode },
{ NationalId: nationalCode },
);
return {
raw: {
nationalCode,
...response,
...policy,
},
};
}
@@ -227,34 +335,53 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
private async inquirePolicyByPlate(
payload: ParsianPolicyByPlatePayload,
): Promise<{ raw: unknown }> {
const nationalCode = this.getRequiredString(payload.nationalCode, 'nationalCode');
const plk1 = this.getRequiredString(payload.plk1, 'plk1');
const plk2 = this.getRequiredString(payload.plk2, 'plk2');
const plk3 = this.getRequiredString(payload.plk3, 'plk3');
const plksrl = this.getRequiredString(payload.plksrl, 'plksrl');
const response = await this.callCarPolicySoap(
const policy = await this.callCarPolicySoap(
InquiryType.POLICY_BY_PLATE,
'CIIWSPolicyVehicleMeli',
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, Plksrl: plksrl },
'PassWord',
{ Plk1: plk1, Plk2: plk2, Plk3: plk3, PlkSrl: plksrl },
);
const policyNationalId = policy.NtnlId?.trim() ?? '';
if (!policyNationalId || !this.nationalIdsMatch(nationalCode, policyNationalId)) {
throw this.formatProviderError(
'عدم تطابق اطلاعات',
'INQUIRY_NO_MATCH',
undefined,
{
conflict: {
nationalCode,
NtnlId: policyNationalId,
},
},
);
}
return {
raw: {
nationalCode,
plk1,
plk2,
plk3,
plksrl,
...response,
...policy,
},
};
}
private nationalIdsMatch(requested: string, fromPolicy: string): boolean {
return requested.trim().padStart(10, '0') === fromPolicy.trim().padStart(10, '0');
}
private async callCarPolicySoap(
inquiryType: InquiryType,
methodName: string,
fields: Record<string, string>,
passwordFieldName = 'Password',
): Promise<{ result: string | null; soap: string }> {
): Promise<Record<string, string>> {
const inquiryConfig = this.config.inquiries[inquiryType];
if (!this.hasSoapCredentials(inquiryConfig)) {
@@ -273,22 +400,24 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
fields,
inquiryConfig.username,
inquiryConfig.password,
passwordFieldName,
),
{
mergeOutboundAxiosConfig({
headers: {
'Content-Type': 'text/xml; charset=utf-8',
SOAPAction: `"http://tempuri.org/ICarAllPlcysV4/${methodName}"`,
SOAPAction: `"http://tempuri.org/ICarAllPlcys/${methodName}"`,
},
timeout: this.config.timeout,
responseType: 'text',
},
}),
);
return {
result: this.extractSoapValue(response.data, `${methodName}Result`),
soap: response.data,
};
const policy = parseFirstCarPolicy(response.data);
const providerError = getCarPolicyProviderError(response.data, policy);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return policy;
} catch (error) {
if (error instanceof AxiosError) {
throw this.formatProviderError(
@@ -323,7 +452,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
);
try {
const response = await axios.post<ParsianSayahResponse>(
const response = await axios.post<SayahApiResponse>(
inquiryConfig.url,
{
AccountOwnerType: accountOwnerType,
@@ -331,23 +460,18 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
LegalId: legalId,
ShebaId: shebaId,
},
{
mergeOutboundAxiosConfig({
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: this.config.timeout,
},
}),
);
if (
(response.data.IsSucceed === false || response.data.isSucceed === false) &&
this.hasErrors(response.data.Errors ?? response.data.errors)
) {
throw this.formatProviderError(
this.formatErrors(response.data.Errors ?? response.data.errors),
'SAYAH_HAS_ERROR',
);
const providerError = getSayahProviderError(response.data);
if (providerError) {
throw this.formatProviderError(providerError.message, providerError.code);
}
return { raw: response.data };
@@ -381,7 +505,6 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
fields: Record<string, string>,
username: string,
password: string,
passwordFieldName: string,
): string {
const fieldXml = Object.entries(fields)
.map(([name, value]) => ` <${name}>${this.escapeXml(value)}</${name}>`)
@@ -394,18 +517,52 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
<soap:Body>
<${methodName} xmlns="http://tempuri.org/">
${fieldXml}
<UserName>${this.escapeXml(username)}</UserName>
<${passwordFieldName}>${this.escapeXml(password)}</${passwordFieldName}>
<Username>${this.escapeXml(username)}</Username>
<PassWrod>${this.escapeXml(password)}</PassWrod>
</${methodName}>
</soap:Body>
</soap:Envelope>`;
}
private extractSoapValue(xml: string, tagName: string): string | null {
const match = xml.match(
new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`),
);
return match ? this.decodeXml(match[1].trim()) : null;
private buildCivilRegistrationEnvelope(
payload: ParsianCivilRegistrationPayload,
nationalCode: string,
birthDate: string,
username: string,
password: string,
): string {
const birthDateCompact = birthDate.replace(/-/g, '');
const dateHasPostfix = payload.dateHasPostfix ?? 0;
return `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<SubmitInqDteStsWithPstCod xmlns="http://tempuri.org/">
<req>
<Nin>${this.escapeXml(nationalCode)}</Nin>
<Name xsi:nil="true"/>
<Family xsi:nil="true"/>
<Fathername xsi:nil="true"/>
<Shenasnameseri xsi:nil="true"/>
<Shenasnameserial>0</Shenasnameserial>
<ShenasnameNo>0</ShenasnameNo>
<BirthDate>${this.escapeXml(birthDateCompact)}</BirthDate>
<DateHasPostfix>${dateHasPostfix}</DateHasPostfix>
<Gender>0</Gender>
<OfficeCode>0</OfficeCode>
<BookNo>0</BookNo>
<NameHasPrefix>0</NameHasPrefix>
<NameHasPostFix>0</NameHasPostFix>
<FamilyHasPrefix>0</FamilyHasPrefix>
<FamilyHasPostFix>0</FamilyHasPostFix>
</req>
<Username>${this.escapeXml(username)}</Username>
<Password>${this.escapeXml(password)}</Password>
</SubmitInqDteStsWithPstCod>
</soap:Body>
</soap:Envelope>`;
}
private escapeXml(value: string): string {
@@ -417,15 +574,6 @@ ${fieldXml}
.replace(/'/g, '&apos;');
}
private decodeXml(value: string): string {
return value
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
private hasErrors(
errors?: Record<string, string> | Array<{ Code?: string; Message?: string }> | null,
): boolean {

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,13 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface';
import { UsersService } from '../users/users.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).
@@ -36,7 +35,7 @@ export class UserRateLimitGuard implements CanActivate {
);
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);

View File

@@ -106,8 +106,15 @@ console.debug = (...args: unknown[]) => {
originalConsole.debug(...args);
};
export function shutdownTelemetry(): Promise<void> {
return sdk.shutdown();
export async function shutdownTelemetry(): Promise<void> {
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 {

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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