Compare commits

...

23 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
54 changed files with 2635 additions and 312 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,6 +21,13 @@ 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=PARSIAN
PERSON_FALLBACK_ENABLED=true

View File

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

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" }]
}
}

165
package-lock.json generated
View File

@@ -53,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",
@@ -2372,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",
@@ -3816,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",
@@ -4066,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",
@@ -4201,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",
@@ -4328,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",
@@ -5061,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",
@@ -5850,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",
@@ -5925,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",
@@ -6143,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",
@@ -7236,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",
@@ -11190,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": {
@@ -60,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,15 +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,10 +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
@@ -39,33 +45,37 @@ export class AllExceptionsFilter implements ExceptionFilter {
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) {
if ('error' in res && isNormalizedError((res as { error: unknown }).error)) {
return (res as { error: NormalizedErrorDto }).error;
return translateError((res as { error: NormalizedErrorDto }).error);
}
if (isNormalizedError(res)) {
return res;
return translateError(res);
}
}
@@ -74,15 +84,22 @@ export class AllExceptionsFilter implements ExceptionFilter {
? res
: (res as { message?: string | string[] }).message;
return {
code: 'HTTP_ERROR',
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message),
};
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: 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,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

@@ -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,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

@@ -1,11 +1,126 @@
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 = '',
@@ -18,7 +133,7 @@ export function flattenValidationErrors(
if (error.constraints) {
result.push({
field,
constraints: Object.values(error.constraints),
constraints: [pickConstraint(error.constraints)],
});
}
@@ -34,18 +149,19 @@ export function buildValidationErrorResponse(errors: ValidationError[]): {
normalizedError: NormalizedErrorDto;
details: ValidationFieldError[];
} {
const details = flattenValidationErrors(errors);
const summary =
details.length > 0
? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ')
: 'Request validation failed';
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: {
code: 'VALIDATION_ERROR',
normalizedError: buildNormalizedError('VALIDATION_ERROR', {
message: summary,
messageFa,
details,
},
}),
details,
};
}

View File

@@ -5,6 +5,7 @@ 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';
@@ -25,11 +26,21 @@ 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);
@@ -42,4 +53,13 @@ export class LoggingInterceptor implements NestInterceptor {
}),
);
}
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

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

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

@@ -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

@@ -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,
@@ -54,19 +57,20 @@ export class InquiryService {
trackingCode,
);
const response = this.buildSuccessResponse(
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,
@@ -76,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,
@@ -114,23 +118,6 @@ export class InquiryService {
});
}
private buildSuccessResponse(
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 (this.isPersonInquiryResult(result)) {
const raw =
@@ -169,16 +156,22 @@ export class InquiryService {
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',
message: error instanceof Error ? error.message : 'Inquiry failed',
normalized: buildNormalizedError('INQUIRY_FAILED', {
message: error instanceof Error ? error.message : 'Inquiry failed',
}),
};
}

View File

@@ -1,5 +1,5 @@
import './telemetry';
import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -8,6 +8,7 @@ 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,
@@ -49,21 +50,37 @@ async function bootstrap(): Promise<void> {
transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors) => {
const { normalizedError } = buildValidationErrorResponse(errors);
return new BadRequestException({
return new AppException('VALIDATION_ERROR', {
message: normalizedError.message,
error: normalizedError,
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.',
'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

@@ -11,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.
@@ -121,26 +127,48 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
}
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
return {
code: partial.code ?? 'PROVIDER_ERROR',
message: partial.message ?? 'Provider request failed',
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 request failed',
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
): Error {
const message = providerMessage?.trim() || fallbackMessage;
const code = providerCode?.trim() || 'PROVIDER_ERROR';
const upstreamCode = providerCode?.trim();
const normalized = this.normalizeError({
code,
message,
code: upstreamCode,
message: fallbackMessage,
providerMessage: message,
providerCode: code,
providerCode: upstreamCode,
...extras,
});
const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
@@ -161,10 +189,23 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
}
}
return this.formatProviderError(
error.message,
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;
}
protected abstract callProvider(

View File

@@ -7,6 +7,11 @@ import {
} 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';
@@ -201,10 +206,21 @@ export class AmitisProvider {
fallbackRefreshToken?: string,
): AmitisAuthToken {
const body = responseBody.data ?? responseBody;
const loginStatus = body.LoginStatus;
if (body.IsSucceed === false) {
throw new Error(
`AMITIS login rejected (LoginStatus=${body.LoginStatus ?? 'unknown'})`,
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',
);
}
@@ -255,23 +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';
const body =
error.response?.data !== undefined
? JSON.stringify(error.response.data).slice(0, 500)
: undefined;
return new Error(
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

@@ -2,10 +2,20 @@ 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';
@@ -15,6 +25,7 @@ import {
LegacyApiProvider,
LegacyInquiryPayload,
LegacyInquiryResult,
PersonInquiryResult,
} from '../shared/legacy-api.provider.abstract';
interface CivilRegistrationPayload extends LegacyInquiryPayload {
@@ -102,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,
@@ -133,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',
@@ -146,6 +158,7 @@ export class HamtaProvider extends LegacyApiProvider {
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCivilRegistrationEnvelope(
payload,
nationalCode,
birthDate,
inquiryConfig.username,
@@ -161,22 +174,23 @@ export class HamtaProvider extends LegacyApiProvider {
}),
);
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,
},
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 this.formatAxiosError(error);
}
throw error;
}
@@ -210,14 +224,22 @@ export class HamtaProvider extends LegacyApiProvider {
}),
);
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) {
@@ -238,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,
@@ -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

@@ -7,6 +7,11 @@ import {
getCivilRegistrationProviderError,
parseCiiEstelamResult,
} from '../../common/helpers/soap-civil-registration.helper';
import {
getShahkarProviderError,
parseShahkarInqueryResult,
describeShahkarResponse,
} from '../../common/helpers/shahkar-response.helper';
import {
getSayahProviderError,
SayahApiResponse,
@@ -218,14 +223,22 @@ export class MoallemProvider extends LegacyApiProvider {
}),
);
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) {

View File

@@ -3,9 +3,15 @@ import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper';
import {
getSayahProviderError,
SayahApiResponse,
} from '../../common/helpers/sayah-response.helper';
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';
@@ -23,6 +29,7 @@ interface ParsianCivilRegistrationPayload extends LegacyInquiryPayload {
birthDate?: string;
NIN?: string;
BirthDate?: string;
dateHasPostfix?: number;
}
interface ParsianShahkarPayload extends LegacyInquiryPayload {
@@ -41,7 +48,7 @@ interface ParsianShahkarResponse {
comment?: string | null;
id?: string | null;
requestId?: string | null;
response?: number;
response?: number | string;
result?: string | null;
}
@@ -74,6 +81,7 @@ interface ParsianPolicyByNationalCodePayload extends LegacyInquiryPayload {
}
interface ParsianPolicyByPlatePayload extends LegacyInquiryPayload {
nationalCode?: string;
plk1?: string;
plk2?: string;
plk3?: string;
@@ -185,6 +193,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
mobileNumber,
},
headers: {
Accept: 'application/json',
'X-PACKAGE-API-KEY': inquiryConfig.apiKey,
},
timeout: this.config.timeout,
@@ -192,6 +201,19 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
);
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,
@@ -233,6 +255,7 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
const response = await axios.post<string>(
inquiryConfig.url,
this.buildCivilRegistrationEnvelope(
payload,
nationalCode,
birthDate,
inquiryConfig.username,
@@ -248,15 +271,19 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
}),
);
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,
raw: {
nationalCode,
birthDate,
result: this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'),
soap: response.data,
},
fullName: fullName || undefined,
raw: civilRegistration,
};
} catch (error) {
if (error instanceof AxiosError) {
@@ -273,16 +300,16 @@ export class ParsianProvider extends BaseProvider<LegacyInquiryPayload, LegacyIn
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,
},
};
}
@@ -291,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,
},
};
}
@@ -308,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)) {
@@ -354,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(
@@ -457,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}>`)
@@ -470,27 +517,47 @@ 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 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/">
<NIN>${this.escapeXml(nationalCode)}</NIN>
<BirthDate>${this.escapeXml(birthDate)}</BirthDate>
<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>
@@ -498,13 +565,6 @@ ${fieldXml}
</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 escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
@@ -514,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

@@ -6,6 +6,7 @@ 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';
@@ -191,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,
};

View File

@@ -1,5 +1,10 @@
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';
@@ -131,6 +136,12 @@ 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) {
const providerError = getSayahProviderError(body);
@@ -142,11 +153,14 @@ export abstract class LegacyApiProvider extends BaseProvider<
// 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 };
}
@@ -172,11 +186,13 @@ export abstract class LegacyApiProvider extends BaseProvider<
}
// 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(
@@ -200,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',
message: `No enabled providers configured for ${inquiryType}`,
...buildNormalizedError('NO_PROVIDERS', {
message: `No enabled providers configured for ${inquiryType}`,
}),
});
}
@@ -71,30 +74,31 @@ export class ProviderOrchestratorService {
const lastError = errors[errors.length - 1]!;
if (providers.length === 1) {
throw new InquiryException(lastError.message, lastError);
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('; '),
{
code: 'ALL_PROVIDERS_FAILED',
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

@@ -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'],
},
],
});
});
});