Compare commits

...

11 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
54 changed files with 1992 additions and 1180 deletions

View File

@@ -1,6 +1,9 @@
# Application # Application
PORT=8085 PORT=8085
NODE_ENV=development NODE_ENV=development
ESG_SMOKE_ENABLED=false
CLIENT_URL=https://apex.mic.co.ir/esg
BASE_URL_PROD=
# MongoDB # MongoDB
MONGODB_URI=mongodb://localhost:27017/inquiry-gateway MONGODB_URI=mongodb://localhost:27017/inquiry-gateway
@@ -18,6 +21,13 @@ BCRYPT_SALT_ROUNDS=12
THROTTLE_TTL=60 THROTTLE_TTL=60
THROTTLE_LIMIT=100 THROTTLE_LIMIT=100
# Route outbound provider API calls through an SSH tunnel (Termius dynamic/SOCKS forward)
# Use socks5h so DNS resolves on the remote server. Leave empty for direct access.
OUTBOUND_PROXY=socks5h://127.0.0.1:6565
# Log every outbound provider HTTP call (request URL, status, response body preview)
OUTBOUND_HTTP_DEBUG=true
# Provider routing (per inquiry type) # Provider routing (per inquiry type)
PERSON_DEFAULT_PROVIDER=PARSIAN PERSON_DEFAULT_PROVIDER=PARSIAN
PERSON_FALLBACK_ENABLED=true PERSON_FALLBACK_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", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": false,
"assets": [{ "include": "public/**/*", "outDir": "dist" }] "assets": [{ "include": "public/**/*", "outDir": "dist" }]
} }
} }

165
package-lock.json generated
View File

@@ -53,12 +53,14 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -2372,6 +2374,19 @@
"reflect-metadata": "^0.1.13 || ^0.2.0" "reflect-metadata": "^0.1.13 || ^0.2.0"
} }
}, },
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nuxtjs/opencollective": { "node_modules/@nuxtjs/opencollective": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz",
@@ -3816,6 +3831,16 @@
"node": "^18.19.0 || >=20.6.0" "node": "^18.19.0 || >=20.6.0"
} }
}, },
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4066,6 +4091,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/cookiejar/-/cookiejar-2.1.5.tgz",
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz",
@@ -4201,6 +4233,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/methods/-/methods-1.1.4.tgz",
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz",
@@ -4328,6 +4367,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/superagent": {
"version": "8.1.10",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/superagent/-/superagent-8.1.10.tgz",
"integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cookiejar": "^2.1.5",
"@types/methods": "^1.1.4",
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/@types/supertest": {
"version": "7.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/supertest/-/supertest-7.2.0.tgz",
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/methods": "^1.1.4",
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/tedious": { "node_modules/@types/tedious": {
"version": "4.0.14", "version": "4.0.14",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz",
@@ -5061,6 +5124,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz",
@@ -5850,6 +5920,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz",
@@ -5925,6 +6005,13 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -6143,6 +6230,17 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
"license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
}
},
"node_modules/diff": { "node_modules/diff": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz",
@@ -7236,6 +7334,24 @@
"node": ">=12.20.0" "node": ">=12.20.0"
} }
}, },
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/formidable/-/formidable-3.5.4.tgz",
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
"once": "^1.4.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz",
@@ -11190,6 +11306,55 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,9 +10,14 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest --selectProjects unit integration admin",
"test:watch": "jest --watch", "test:unit": "jest --selectProjects unit",
"test:cov": "jest --coverage", "test:integration": "jest --selectProjects integration",
"test:admin": "jest --selectProjects admin",
"test:watch": "jest --selectProjects unit integration admin --watch",
"test:cov": "jest --selectProjects unit integration admin --coverage",
"smoke": "ESG_SMOKE_ENABLED=true jest --selectProjects smoke --runInBand",
"smoke:client": "npm run smoke --",
"cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts" "cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts"
}, },
"dependencies": { "dependencies": {
@@ -60,33 +65,18 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^8.18.2",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
} }
} }

View File

@@ -11,7 +11,6 @@ import { RateLimitModule } from './rate-limit/rate-limit.module';
import { LoggingModule } from './logging/logging.module'; import { LoggingModule } from './logging/logging.module';
import { ProvidersModule } from './providers/providers.module'; import { ProvidersModule } from './providers/providers.module';
import { InquiryModule } from './inquiry/inquiry.module'; import { InquiryModule } from './inquiry/inquiry.module';
import { ClaimsModule } from './claims/claims.module';
import { DatabaseSeederModule } from './database/database-seeder.module'; import { DatabaseSeederModule } from './database/database-seeder.module';
/** /**
@@ -35,7 +34,6 @@ import { DatabaseSeederModule } from './database/database-seeder.module';
DatabaseSeederModule, DatabaseSeederModule,
ProvidersModule, ProvidersModule,
InquiryModule, InquiryModule,
ClaimsModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
import { Body, Controller, Post, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ResponseTransformInterceptor } from '../common/interceptors/response-transform.interceptor';
import { UserRateLimitGuard } from '../rate-limit/user-rate-limit.guard';
import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto';
import { ThirdPartyClaimService, ThirdPartyClaimSubmitResult } from './third-party-claim.service';
@ApiTags('Claims')
@ApiBearerAuth()
@Controller('claims')
@UseGuards(JwtAuthGuard, UserRateLimitGuard)
@UseInterceptors(ResponseTransformInterceptor)
export class ClaimsController {
constructor(private readonly thirdPartyClaimService: ThirdPartyClaimService) {}
@Post('third-party/submit')
@Throttle({ default: { limit: 20, ttl: 60000 } })
@ApiOperation({
summary: 'Submit a third-party car financial claim to Fanavaran',
description:
'Authenticates with Fanavaran, resolves the latest third-party policy for the national code, ' +
'resolves ClaimExpertId from evaluation history when provided, and submits the claim.',
})
@ApiResponse({ status: 201, description: 'Claim submitted or structured failure returned' })
async submitThirdPartyClaim(
@Body() dto: SubmitThirdPartyClaimDto,
): Promise<ThirdPartyClaimSubmitResult> {
return this.thirdPartyClaimService.submit(dto);
}
}

View File

@@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AuthModule } from '../auth/auth.module';
import { ProvidersModule } from '../providers/providers.module';
import { RateLimitModule } from '../rate-limit/rate-limit.module';
import { ClaimsController } from './claims.controller';
import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service';
import { FanavaranApiService } from './fanavaran/fanavaran-api.service';
import { EvaluationRequest, EvaluationRequestSchema } from './schemas/evaluation-request.schema';
import { FieldExpert, FieldExpertSchema } from './schemas/field-expert.schema';
import { ClaimExpertResolverService } from './services/claim-expert-resolver.service';
import { EvaluationRequestService } from './services/evaluation-request.service';
import { ThirdPartyClaimService } from './third-party-claim.service';
@Module({
imports: [
AuthModule,
ProvidersModule,
RateLimitModule,
MongooseModule.forFeature([
{ name: FieldExpert.name, schema: FieldExpertSchema },
{ name: EvaluationRequest.name, schema: EvaluationRequestSchema },
]),
],
controllers: [ClaimsController],
providers: [
FanavaranAuthService,
FanavaranApiService,
EvaluationRequestService,
ClaimExpertResolverService,
ThirdPartyClaimService,
],
exports: [ThirdPartyClaimService, FanavaranApiService, ClaimExpertResolverService],
})
export class ClaimsModule {}

View File

@@ -1,312 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Length,
Matches,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { FanavaranInsuranceLineId } from '../fanavaran/fanavaran.types';
export class SubmitThirdPartyClaimDto {
@ApiProperty({ example: '0059132574', description: 'Policy holder national code (10 digits)' })
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d{10}$/, { message: 'nationalCode must be exactly 10 digits' })
nationalCode!: string;
@ApiPropertyOptional({
example: FanavaranInsuranceLineId.THIRD_PARTY,
enum: FanavaranInsuranceLineId,
default: FanavaranInsuranceLineId.THIRD_PARTY,
})
@IsOptional()
@IsEnum(FanavaranInsuranceLineId)
@Type(() => Number)
insuranceLineId?: FanavaranInsuranceLineId;
@ApiPropertyOptional({
description: 'Override ClaimExpertId instead of resolving from evaluation history',
example: 4662,
})
@IsOptional()
@IsInt()
@Type(() => Number)
claimExpertId?: number;
@ApiPropertyOptional({
description: 'Evaluation/claim case id — latest expert from history is used when claimExpertId is omitted',
example: 'CASE-2026-001',
})
@IsOptional()
@IsString()
evaluationRequestId?: string;
@ApiPropertyOptional({
description: 'Use a specific policy id instead of resolving the latest policy from Fanavaran',
example: 14821240,
})
@IsOptional()
@IsInt()
@Type(() => Number)
policyId?: number;
@ApiProperty({ example: 701 })
@IsInt()
@Type(() => Number)
AccidentCityId!: number;
@ApiProperty({ example: 155 })
@IsInt()
@Type(() => Number)
AccidentReportTypeId!: number;
@ApiProperty({ example: 1 })
@IsInt()
@Type(() => Number)
AccidentVehicleUsedId!: number;
@ApiProperty({ example: 167 })
@IsInt()
@Type(() => Number)
CompensationReferenceId!: number;
@ApiProperty({ example: 2 })
@IsInt()
@Type(() => Number)
CulpritLicenceTypeId!: number;
@ApiProperty({ example: 337 })
@IsInt()
@Type(() => Number)
CulpritTypeId!: number;
@ApiProperty({ example: 6 })
@IsInt()
@Type(() => Number)
AccidentCauseId!: number;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
AccidentDate!: string;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
AnnouncementDate!: string;
@ApiProperty({ example: '1405/03/17' })
@IsString()
@IsNotEmpty()
DocReceivedDate!: string;
@ApiProperty({ example: '15:59' })
@IsString()
@IsNotEmpty()
AccidentTime!: string;
@ApiProperty({ example: 'تهران، محله کوی نصر، بزرگراه جلال آل احمد' })
@IsString()
@IsNotEmpty()
AccidentLocationAddress!: string;
@ApiProperty({ example: 143 })
@IsNumber()
@Min(0)
@Type(() => Number)
EstimateAmount!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
AccidentCulpritId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CouponNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CourtArchiveNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
CulpritLicenceCityId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
CulpritLicenceCountryId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
CulpritLicenceForeignCityName?: string | null;
@ApiProperty({ example: '1394/10/13' })
@IsString()
@IsNotEmpty()
CulpritLicenceIssuDate!: string;
@ApiProperty({ example: '1124242' })
@IsString()
@IsNotEmpty()
CulpritLicenceNo!: string;
@ApiProperty({ example: 1 })
@IsInt()
@Min(1)
@Type(() => Number)
DamagedCount!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
DmgAssessorFirstCreationTime?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
EntryDate?: string | null;
@ApiProperty({ example: 1 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsLicenseMatchWithVehicleKind!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
HasOtherCulprit!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsAccidentOutOfBorder!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsFatalAccident!: number;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsPlaqueChanged!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueCityId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueKindId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueLeftNo?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueMiddleCodeId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PlaqueNo?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueRightNo?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueSampleId?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
PlaqueSerial?: number | null;
@ApiProperty({ example: 1 })
@IsInt()
@Type(() => Number)
PoliceOfficerId!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportDesc?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportSeri?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
PoliceReportSerial?: string | null;
@ApiPropertyOptional({ example: '', default: '' })
@IsOptional()
@IsString()
PreviousPolicyEndDate?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
TrackingCode?: string | null;
@ApiProperty({ example: 0 })
@IsInt()
@Min(0)
@Max(1)
@Type(() => Number)
IsLicenseReplacement!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsInt()
@Type(() => Number)
UnknownCulpritCauseId?: number | null;
}

View File

@@ -1,119 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError, AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import { FanavaranConfig } from '../../config/configuration';
import { FanavaranAuthService } from './fanavaran-auth.service';
import {
FanavaranInsuranceLineId,
FanavaranPolicySummary,
ThirdPartyCarFinancialClaimPayload,
} from './fanavaran.types';
@Injectable()
export class FanavaranApiService {
private readonly logger = new Logger(FanavaranApiService.name);
private readonly httpClient: AxiosInstance;
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
private readonly authService: FanavaranAuthService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
}
async inquiryLatestPolicy(
nationalCode: string,
insuranceLineId: FanavaranInsuranceLineId = FanavaranInsuranceLineId.THIRD_PARTY,
): Promise<FanavaranPolicySummary> {
const authenticationToken = await this.authService.getAuthenticationToken();
const response = await this.httpClient.get<FanavaranPolicySummary[]>(
'/api/BimeApi/v2.0/common/Policies/inquiry-my-policies',
{
params: {
InsuranceLineId: insuranceLineId,
NationalCode: nationalCode,
},
headers: this.buildCommonHeaders(authenticationToken, this.config.policyInquiryLocation),
},
);
const policies = Array.isArray(response.data) ? response.data : [];
if (policies.length === 0) {
throw new Error(
`No Fanavaran policies found for nationalCode=${nationalCode}, insuranceLineId=${insuranceLineId}`,
);
}
const latest = this.pickLatestPolicy(policies);
this.logger.log(
`Fanavaran policy selected | nationalCode=${nationalCode} | policyId=${latest.PolicyId} | issuDate=${latest.IssuDate}`,
);
return latest;
}
async submitThirdPartyCarFinancialClaim(
payload: ThirdPartyCarFinancialClaimPayload,
): Promise<unknown> {
const authenticationToken = await this.authService.getAuthenticationToken();
try {
const response = await this.httpClient.post(
'/api/BimeApi/v2.0/car/third-party-car-financial-claims',
payload,
{
headers: this.buildCommonHeaders(authenticationToken, this.config.claimSubmitLocation),
},
);
return response.data;
} catch (error) {
throw this.formatApiError(error);
}
}
pickLatestPolicy(policies: FanavaranPolicySummary[]): FanavaranPolicySummary {
return [...policies].sort((left, right) => {
const dateCompare = right.IssuDate.localeCompare(left.IssuDate);
if (dateCompare !== 0) {
return dateCompare;
}
return right.PolicyId - left.PolicyId;
})[0];
}
private buildCommonHeaders(
authenticationToken: string,
location: number,
): Record<string, string | number> {
return {
authenticationToken,
CorpId: this.config.corpId,
ContractId: this.config.contractId,
Location: location,
};
}
private formatApiError(error: unknown): Error {
if (!(error instanceof AxiosError)) {
return error instanceof Error ? error : new Error(String(error));
}
const status = error.response?.status;
const body =
typeof error.response?.data === 'string'
? error.response.data
: JSON.stringify(error.response?.data ?? {});
return new Error(
`Fanavaran API request failed${status ? ` (${status})` : ''}: ${error.message} | body=${body}`,
);
}
}

View File

@@ -1,141 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosInstance } from 'axios';
import { createOutboundAxiosInstance } from '../../common/helpers/http-client.helper';
import { FanavaranConfig } from '../../config/configuration';
import { GeneralTokenService } from '../../providers/services/general-token.service';
import { FanavaranLoginUserInfo } from './fanavaran.types';
const APP_TOKEN_PROVIDER = 'Fanavaran:AppToken';
const AUTH_TOKEN_PROVIDER = 'Fanavaran:AuthToken';
@Injectable()
export class FanavaranAuthService {
private readonly logger = new Logger(FanavaranAuthService.name);
private readonly httpClient: AxiosInstance;
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
private readonly generalTokenService: GeneralTokenService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
this.httpClient = createOutboundAxiosInstance({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
});
}
isEnabled(): boolean {
return (
this.config.enabled &&
Boolean(this.config.baseUrl) &&
Boolean(this.config.appName) &&
Boolean(this.config.appSecret) &&
Boolean(this.config.userName) &&
Boolean(this.config.password)
);
}
async getAuthenticationToken(): Promise<string> {
const cached = await this.generalTokenService.getLatestToken(AUTH_TOKEN_PROVIDER);
if (cached && !(await this.generalTokenService.isTokenExpired(cached))) {
return cached.accessToken;
}
const appToken = await this.getAppToken();
const { authenticationToken } = await this.login(appToken);
return authenticationToken;
}
private async getAppToken(): Promise<string> {
const cached = await this.generalTokenService.getLatestToken(APP_TOKEN_PROVIDER);
if (cached && !(await this.generalTokenService.isTokenExpired(cached))) {
return cached.accessToken;
}
const response = await this.httpClient.post(
'/api/EITAuthentication/GetAppToken',
undefined,
{
headers: {
appname: this.config.appName,
secret: this.config.appSecret,
},
},
);
const appToken = this.readHeader(response.headers, 'apptoken');
if (!appToken) {
throw new Error('Fanavaran GetAppToken did not return appToken header');
}
await this.cacheToken(APP_TOKEN_PROVIDER, appToken, this.config.appTokenTtlSeconds);
return appToken;
}
private async login(appToken: string): Promise<{
authenticationToken: string;
user: FanavaranLoginUserInfo;
}> {
const response = await this.httpClient.post('/api/EITAuthentication/Login', undefined, {
headers: {
appToken,
userName: this.config.userName,
password: this.config.password,
},
});
const authenticationToken = this.readHeader(response.headers, 'authenticationtoken');
if (!authenticationToken) {
throw new Error('Fanavaran Login did not return authenticationToken header');
}
await this.cacheToken(
AUTH_TOKEN_PROVIDER,
authenticationToken,
this.config.authTokenTtlSeconds,
);
const user = response.data as FanavaranLoginUserInfo;
this.logger.log(
`Fanavaran login succeeded | user=${user?.UserName ?? this.config.userName}`,
);
return { authenticationToken, user };
}
private async cacheToken(
serviceProvider: string,
token: string,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await this.generalTokenService.create({
serviceProvider,
tokenType: 'Bearer',
url: this.config.baseUrl,
clientId: this.config.appName,
clientSecret: '***',
username: this.config.userName,
scope: serviceProvider,
accessToken: token,
expiresIn: ttlSeconds,
expiresAt,
});
}
private readHeader(
headers: Record<string, unknown>,
name: string,
): string | undefined {
const value = headers[name];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0].trim();
}
return undefined;
}
}

View File

@@ -1,81 +0,0 @@
/** Fanavaran (Iran EIT) insurance line identifiers. */
export enum FanavaranInsuranceLineId {
CAR_BODY = 4,
THIRD_PARTY = 5,
}
export interface FanavaranPolicySummary {
BeginDate: string;
CustomerId: number;
EffectiveDate: string | null;
EndDate: string;
EndoNo: number;
InsuranceLineId: number;
IsClosed: number;
IssuDate: string;
OpUnitId: number;
PayerCustomerId: number;
PersonRoleId: number;
PolicyGroupId: number;
PolicyId: number;
PolicyNo: number;
PolicyVerNo: number;
Status: number;
TranTypeId: number;
}
export interface FanavaranLoginUserInfo {
UserName: string;
UserId: string;
FarsiName: string | null;
}
export interface ThirdPartyCarFinancialClaimPayload {
AccidentCityId: number;
AccidentReportTypeId: number;
AccidentVehicleUsedId: number;
ClaimExpertId: number;
CompensationReferenceId: number;
CulpritLicenceTypeId: number;
CulpritTypeId: number;
AccidentCauseId: number;
AccidentDate: string;
AnnouncementDate: string;
DocReceivedDate: string;
AccidentTime: string;
AccidentLocationAddress: string;
EstimateAmount: number;
AccidentCulpritId: number | null;
CouponNo: string | null;
CourtArchiveNo: string | null;
CulpritLicenceCityId: number | null;
CulpritLicenceCountryId: number | null;
CulpritLicenceForeignCityName: string | null;
CulpritLicenceIssuDate: string;
CulpritLicenceNo: string;
DamagedCount: number;
DmgAssessorFirstCreationTime: string | null;
EntryDate: string | null;
IsLicenseMatchWithVehicleKind: number;
HasOtherCulprit: number;
IsAccidentOutOfBorder: number;
IsFatalAccident: number;
IsPlaqueChanged: number;
PlaqueCityId: number | null;
PlaqueKindId: number | null;
PlaqueLeftNo: number | null;
PlaqueMiddleCodeId: number | null;
PlaqueNo: string | null;
PlaqueRightNo: number | null;
PlaqueSampleId: number | null;
PlaqueSerial: number | null;
PoliceOfficerId: number;
PoliceReportDesc: string | null;
PoliceReportSeri: string | null;
PoliceReportSerial: string | null;
PolicyId: number;
PreviousPolicyEndDate: string;
TrackingCode: string | null;
IsLicenseReplacement: number;
UnknownCulpritCauseId: number | null;
}

View File

@@ -1,30 +0,0 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type EvaluationRequestDocument = HydratedDocument<EvaluationRequest>;
@Schema({ _id: false })
export class EvaluationHistoryEntry {
@Prop({ type: Types.ObjectId, ref: 'FieldExpert', required: true })
expertId!: Types.ObjectId;
@Prop()
expertCode?: number;
@Prop({ required: true, default: () => new Date() })
evaluatedAt!: Date;
}
export const EvaluationHistoryEntrySchema =
SchemaFactory.createForClass(EvaluationHistoryEntry);
@Schema({ timestamps: true, collection: 'evaluation_requests' })
export class EvaluationRequest {
@Prop({ required: true, unique: true, trim: true })
requestId!: string;
@Prop({ type: [EvaluationHistoryEntrySchema], default: [] })
evaluations!: EvaluationHistoryEntry[];
}
export const EvaluationRequestSchema = SchemaFactory.createForClass(EvaluationRequest);

View File

@@ -1,18 +0,0 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
@Schema({ timestamps: true, collection: 'field_experts' })
export class FieldExpert {
@Prop({ required: true, trim: true })
name!: string;
@Prop({ required: true, unique: true })
expertCode!: number;
@Prop({ default: true })
active!: boolean;
}
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);

View File

@@ -1,72 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { ConfigService } from '@nestjs/config';
import { FanavaranConfig } from '../../config/configuration';
import { FieldExpert, FieldExpertDocument } from '../schemas/field-expert.schema';
import { EvaluationRequestService } from './evaluation-request.service';
export interface ClaimExpertResolutionInput {
claimExpertId?: number;
evaluationRequestId?: string;
}
@Injectable()
export class ClaimExpertResolverService {
private readonly config: FanavaranConfig;
constructor(
configService: ConfigService,
@InjectModel(FieldExpert.name)
private readonly fieldExpertModel: Model<FieldExpertDocument>,
private readonly evaluationRequestService: EvaluationRequestService,
) {
this.config = configService.get<FanavaranConfig>('fanavaran')!;
}
async resolveClaimExpertId(input: ClaimExpertResolutionInput): Promise<number> {
if (input.claimExpertId !== undefined && input.claimExpertId !== null) {
return input.claimExpertId;
}
if (input.evaluationRequestId) {
const resolved = await this.resolveFromEvaluationHistory(input.evaluationRequestId);
if (resolved !== undefined) {
return resolved;
}
}
return this.config.defaultClaimExpertId;
}
private async resolveFromEvaluationHistory(
evaluationRequestId: string,
): Promise<number | undefined> {
const evaluationRequest =
await this.evaluationRequestService.findByRequestId(evaluationRequestId);
if (!evaluationRequest?.evaluations?.length) {
return undefined;
}
const latestEvaluation = [...evaluationRequest.evaluations].sort(
(left, right) => right.evaluatedAt.getTime() - left.evaluatedAt.getTime(),
)[0];
if (latestEvaluation.expertCode !== undefined && latestEvaluation.expertCode !== null) {
return latestEvaluation.expertCode;
}
if (!latestEvaluation.expertId) {
return undefined;
}
const expert = await this.fieldExpertModel
.findOne({
_id: new Types.ObjectId(latestEvaluation.expertId),
active: true,
})
.exec();
return expert?.expertCode;
}
}

View File

@@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import {
EvaluationRequest,
EvaluationRequestDocument,
} from '../schemas/evaluation-request.schema';
@Injectable()
export class EvaluationRequestService {
constructor(
@InjectModel(EvaluationRequest.name)
private readonly evaluationRequestModel: Model<EvaluationRequestDocument>,
) {}
async findByRequestId(requestId: string): Promise<EvaluationRequestDocument | null> {
return this.evaluationRequestModel.findOne({ requestId }).exec();
}
}

View File

@@ -1,159 +0,0 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { SubmitThirdPartyClaimDto } from './dto/submit-third-party-claim.dto';
import { FanavaranApiService } from './fanavaran/fanavaran-api.service';
import { FanavaranAuthService } from './fanavaran/fanavaran-auth.service';
import {
FanavaranInsuranceLineId,
FanavaranPolicySummary,
ThirdPartyCarFinancialClaimPayload,
} from './fanavaran/fanavaran.types';
import { ClaimExpertResolverService } from './services/claim-expert-resolver.service';
export interface ThirdPartyClaimSubmitResult {
success: boolean;
trackingCode: string;
message: string;
duration: number;
data: {
policyId: number;
claimExpertId: number;
policy: FanavaranPolicySummary;
fanavaranResponse: unknown;
} | null;
error: { code: string; message: string } | null;
}
@Injectable()
export class ThirdPartyClaimService {
private readonly logger = new Logger(ThirdPartyClaimService.name);
constructor(
private readonly fanavaranAuthService: FanavaranAuthService,
private readonly fanavaranApiService: FanavaranApiService,
private readonly claimExpertResolver: ClaimExpertResolverService,
) {}
async submit(dto: SubmitThirdPartyClaimDto): Promise<ThirdPartyClaimSubmitResult> {
const trackingCode = generateTrackingCode();
const start = Date.now();
if (!this.fanavaranAuthService.isEnabled()) {
throw new ServiceUnavailableException('Fanavaran integration is not configured');
}
try {
const insuranceLineId =
dto.insuranceLineId ?? FanavaranInsuranceLineId.THIRD_PARTY;
const claimExpertId = await this.claimExpertResolver.resolveClaimExpertId({
claimExpertId: dto.claimExpertId,
evaluationRequestId: dto.evaluationRequestId,
});
const policy =
dto.policyId !== undefined
? ({ PolicyId: dto.policyId } as FanavaranPolicySummary)
: await this.fanavaranApiService.inquiryLatestPolicy(
dto.nationalCode,
insuranceLineId,
);
const payload = this.buildClaimPayload(dto, policy.PolicyId, claimExpertId);
const fanavaranResponse =
await this.fanavaranApiService.submitThirdPartyCarFinancialClaim(payload);
const duration = Date.now() - start;
this.logger.log(
`Third-party claim submitted | trackingCode=${trackingCode} | policyId=${policy.PolicyId} | claimExpertId=${claimExpertId} | durationMs=${duration}`,
);
return {
success: true,
trackingCode,
message: 'Third-party claim submitted successfully',
duration,
data: {
policyId: policy.PolicyId,
claimExpertId,
policy,
fanavaranResponse,
},
error: null,
};
} catch (error) {
const duration = Date.now() - start;
const message = error instanceof Error ? error.message : 'Third-party claim submission failed';
this.logger.error(
`Third-party claim failed | trackingCode=${trackingCode} | durationMs=${duration} | ${message}`,
);
return {
success: false,
trackingCode,
message,
duration,
data: null,
error: {
code: 'THIRD_PARTY_CLAIM_FAILED',
message,
},
};
}
}
private buildClaimPayload(
dto: SubmitThirdPartyClaimDto,
policyId: number,
claimExpertId: number,
): ThirdPartyCarFinancialClaimPayload {
return {
AccidentCityId: dto.AccidentCityId,
AccidentReportTypeId: dto.AccidentReportTypeId,
AccidentVehicleUsedId: dto.AccidentVehicleUsedId,
ClaimExpertId: claimExpertId,
CompensationReferenceId: dto.CompensationReferenceId,
CulpritLicenceTypeId: dto.CulpritLicenceTypeId,
CulpritTypeId: dto.CulpritTypeId,
AccidentCauseId: dto.AccidentCauseId,
AccidentDate: dto.AccidentDate,
AnnouncementDate: dto.AnnouncementDate,
DocReceivedDate: dto.DocReceivedDate,
AccidentTime: dto.AccidentTime,
AccidentLocationAddress: dto.AccidentLocationAddress,
EstimateAmount: dto.EstimateAmount,
AccidentCulpritId: dto.AccidentCulpritId ?? null,
CouponNo: dto.CouponNo ?? null,
CourtArchiveNo: dto.CourtArchiveNo ?? null,
CulpritLicenceCityId: dto.CulpritLicenceCityId ?? null,
CulpritLicenceCountryId: dto.CulpritLicenceCountryId ?? null,
CulpritLicenceForeignCityName: dto.CulpritLicenceForeignCityName ?? null,
CulpritLicenceIssuDate: dto.CulpritLicenceIssuDate,
CulpritLicenceNo: dto.CulpritLicenceNo,
DamagedCount: dto.DamagedCount,
DmgAssessorFirstCreationTime: dto.DmgAssessorFirstCreationTime ?? null,
EntryDate: dto.EntryDate ?? null,
IsLicenseMatchWithVehicleKind: dto.IsLicenseMatchWithVehicleKind,
HasOtherCulprit: dto.HasOtherCulprit,
IsAccidentOutOfBorder: dto.IsAccidentOutOfBorder,
IsFatalAccident: dto.IsFatalAccident,
IsPlaqueChanged: dto.IsPlaqueChanged,
PlaqueCityId: dto.PlaqueCityId ?? null,
PlaqueKindId: dto.PlaqueKindId ?? null,
PlaqueLeftNo: dto.PlaqueLeftNo ?? null,
PlaqueMiddleCodeId: dto.PlaqueMiddleCodeId ?? null,
PlaqueNo: dto.PlaqueNo ?? null,
PlaqueRightNo: dto.PlaqueRightNo ?? null,
PlaqueSampleId: dto.PlaqueSampleId ?? null,
PlaqueSerial: dto.PlaqueSerial ?? null,
PoliceOfficerId: dto.PoliceOfficerId,
PoliceReportDesc: dto.PoliceReportDesc ?? null,
PoliceReportSeri: dto.PoliceReportSeri ?? null,
PoliceReportSerial: dto.PoliceReportSerial ?? null,
PolicyId: policyId,
PreviousPolicyEndDate: dto.PreviousPolicyEndDate ?? '',
TrackingCode: dto.TrackingCode ?? null,
IsLicenseReplacement: dto.IsLicenseReplacement,
UnknownCulpritCauseId: dto.UnknownCulpritCauseId ?? null,
};
}
}

View File

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

View File

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

View File

@@ -18,6 +18,9 @@ export class BaseInquiryResponseDto<T = Record<string, unknown>> {
@ApiPropertyOptional({ example: 'Inquiry completed successfully' }) @ApiPropertyOptional({ example: 'Inquiry completed successfully' })
message?: string; message?: string;
@ApiPropertyOptional({ example: 'استعلام با موفقیت انجام شد' })
messageFa?: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
data?: T | null; data?: T | null;

View File

@@ -11,6 +11,9 @@ export class NormalizedErrorDto {
@ApiProperty({ example: 'Provider request timed out' }) @ApiProperty({ example: 'Provider request timed out' })
message!: string; message!: string;
@ApiPropertyOptional({ example: 'درخواست به سرویسدهنده زمانبر شد' })
messageFa?: string;
@ApiPropertyOptional({ example: 'سرویس در دسترس نیست' }) @ApiPropertyOptional({ example: 'سرویس در دسترس نیست' })
providerMessage?: string; providerMessage?: string;

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

@@ -11,6 +11,12 @@ import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { buildInquiryResponse } from '../helpers/inquiry-response.helper'; import { buildInquiryResponse } from '../helpers/inquiry-response.helper';
import { ProviderException } from '../exceptions/provider.exception'; import { ProviderException } from '../exceptions/provider.exception';
import { isNormalizedError } from '../helpers/validation-error.helper'; import { isNormalizedError } from '../helpers/validation-error.helper';
import { translateError } from '../helpers/translate-error.helper';
import { AppException } from '../exceptions/app-exception';
import {
buildNormalizedError,
resolveErrorCodeFromMessage,
} from '../constants/error-messages';
/** /**
* Global exception filter — normalizes all errors into BaseInquiryResponseDto * Global exception filter — normalizes all errors into BaseInquiryResponseDto
@@ -52,20 +58,24 @@ export class AllExceptionsFilter implements ExceptionFilter {
} }
private extractNormalizedError(exception: unknown): NormalizedErrorDto { private extractNormalizedError(exception: unknown): NormalizedErrorDto {
if (exception instanceof ProviderException) { if (exception instanceof AppException) {
return exception.normalizedError; return exception.normalizedError;
} }
if (exception instanceof ProviderException) {
return translateError(exception.normalizedError);
}
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
const res = exception.getResponse(); const res = exception.getResponse();
if (typeof res === 'object' && res !== null) { if (typeof res === 'object' && res !== null) {
if ('error' in res && isNormalizedError((res as { error: unknown }).error)) { 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)) { if (isNormalizedError(res)) {
return res; return translateError(res);
} }
} }
@@ -74,15 +84,22 @@ export class AllExceptionsFilter implements ExceptionFilter {
? res ? res
: (res as { message?: string | string[] }).message; : (res as { message?: string | string[] }).message;
return { const normalizedMessage = Array.isArray(message)
code: 'HTTP_ERROR', ? message.join(', ')
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), : String(message ?? exception.message);
}; const code = resolveErrorCodeFromMessage(normalizedMessage);
return code
? buildNormalizedError(code, { message: normalizedMessage })
: translateError({
code: 'HTTP_ERROR',
message: normalizedMessage,
});
} }
return { return translateError({
code: 'INTERNAL_ERROR', code: 'INTERNAL_ERROR',
message: exception instanceof Error ? exception.message : 'Unexpected error', message: exception instanceof Error ? exception.message : 'Unexpected error',
}; });
} }
} }

View File

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

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 { ValidationError } from 'class-validator';
import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto';
import { buildNormalizedError } from '../constants/error-messages';
export interface ValidationFieldError { export interface ValidationFieldError {
field: string; field: string;
constraints: 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( export function flattenValidationErrors(
errors: ValidationError[], errors: ValidationError[],
parentPath = '', parentPath = '',
@@ -18,7 +133,7 @@ export function flattenValidationErrors(
if (error.constraints) { if (error.constraints) {
result.push({ result.push({
field, field,
constraints: Object.values(error.constraints), constraints: [pickConstraint(error.constraints)],
}); });
} }
@@ -34,18 +149,19 @@ export function buildValidationErrorResponse(errors: ValidationError[]): {
normalizedError: NormalizedErrorDto; normalizedError: NormalizedErrorDto;
details: ValidationFieldError[]; details: ValidationFieldError[];
} { } {
const details = flattenValidationErrors(errors); const details = flattenValidationErrors(errors).slice(0, 1);
const summary = const selected = details[0];
details.length > 0 const summary = selected?.constraints[0] ?? 'Request validation failed';
? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ') const messageFa = selected
: 'Request validation failed'; ? translateValidationConstraint(selected.field, selected.constraints[0] ?? '')
: 'اطلاعات ورودی نامعتبر است';
return { return {
normalizedError: { normalizedError: buildNormalizedError('VALIDATION_ERROR', {
code: 'VALIDATION_ERROR',
message: summary, message: summary,
messageFa,
details, details,
}, }),
details, details,
}; };
} }

View File

@@ -27,23 +27,6 @@ export interface AmitisAuthServiceConfig {
enabled: boolean; enabled: boolean;
} }
export interface FanavaranConfig {
enabled: boolean;
baseUrl: string;
timeout: number;
appName: string;
appSecret: string;
userName: string;
password: string;
corpId: number;
contractId: number;
policyInquiryLocation: number;
claimSubmitLocation: number;
defaultClaimExpertId: number;
appTokenTtlSeconds: number;
authTokenTtlSeconds: number;
}
export interface TejaratNouConfig { export interface TejaratNouConfig {
enabled: boolean; enabled: boolean;
timeout: number; timeout: number;
@@ -98,24 +81,6 @@ export default () => ({
hamta: buildProviderConfig('HAMTA'), hamta: buildProviderConfig('HAMTA'),
moallem: buildProviderConfig('MOALLEM'), moallem: buildProviderConfig('MOALLEM'),
parsian: buildProviderConfig('PARSIAN'), parsian: buildProviderConfig('PARSIAN'),
fanavaran: {
enabled: process.env.FANAVARAN_ENABLED !== 'false',
baseUrl:
process.env.FANAVARAN_BASE_URL ??
'https://apimanager.iraneit.com/BimeApiManager',
timeout: parseInt(process.env.FANAVARAN_TIMEOUT ?? '30000', 10),
appName: process.env.FANAVARAN_APP_NAME ?? '',
appSecret: process.env.FANAVARAN_APP_SECRET ?? '',
userName: process.env.FANAVARAN_USERNAME ?? '',
password: process.env.FANAVARAN_PASSWORD ?? '',
corpId: parseInt(process.env.FANAVARAN_CORP_ID ?? '543', 10),
contractId: parseInt(process.env.FANAVARAN_CONTRACT_ID ?? '28', 10),
policyInquiryLocation: parseInt(process.env.FANAVARAN_POLICY_INQUIRY_LOCATION ?? '100100', 10),
claimSubmitLocation: parseInt(process.env.FANAVARAN_CLAIM_SUBMIT_LOCATION ?? '210050', 10),
defaultClaimExpertId: parseInt(process.env.FANAVARAN_DEFAULT_CLAIM_EXPERT_ID ?? '4662', 10),
appTokenTtlSeconds: parseInt(process.env.FANAVARAN_APP_TOKEN_TTL_SECONDS ?? '3600', 10),
authTokenTtlSeconds: parseInt(process.env.FANAVARAN_AUTH_TOKEN_TTL_SECONDS ?? '3600', 10),
} satisfies FanavaranConfig,
tejaratnou: { tejaratnou: {
enabled: process.env.TEJARATNOU_ENABLED !== 'false', enabled: process.env.TEJARATNOU_ENABLED !== 'false',
timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10),

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import { generateTrackingCode } from '../common/helpers/tracking-code.helper';
import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper'; import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper';
import { InquiryException } from '../common/exceptions/inquiry.exception'; import { InquiryException } from '../common/exceptions/inquiry.exception';
import { InquiryLogService } from '../logging/inquiry-log.service'; import { InquiryLogService } from '../logging/inquiry-log.service';
import { translateError } from '../common/helpers/translate-error.helper';
import { buildNormalizedError } from '../common/constants/error-messages';
import { import {
LegacyInquiryPayload, LegacyInquiryPayload,
PersonInquiryPayload, PersonInquiryPayload,
@@ -159,18 +161,17 @@ export class InquiryService {
provider?: string; provider?: string;
} { } {
if (error instanceof InquiryException) { if (error instanceof InquiryException) {
return { normalized: error.normalizedError, provider: error.provider }; return { normalized: translateError(error.normalizedError), provider: error.provider };
} }
if (error && typeof error === 'object' && 'normalizedError' in error) { if (error && typeof error === 'object' && 'normalizedError' in error) {
return { return {
normalized: (error as { normalizedError: NormalizedErrorDto }).normalizedError, normalized: translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError),
}; };
} }
return { return {
normalized: { normalized: buildNormalizedError('INQUIRY_FAILED', {
code: 'INQUIRY_FAILED',
message: error instanceof Error ? error.message : 'Inquiry failed', message: error instanceof Error ? error.message : 'Inquiry failed',
}, }),
}; };
} }

View File

@@ -1,5 +1,5 @@
import './telemetry'; import './telemetry';
import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 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 { API_KEY_HEADER } from './common/constants/app.constants';
import { OpenTelemetryNestLogger } from './otel-nest-logger'; import { OpenTelemetryNestLogger } from './otel-nest-logger';
import { buildValidationErrorResponse } from './common/helpers/validation-error.helper'; import { buildValidationErrorResponse } from './common/helpers/validation-error.helper';
import { AppException } from './common/exceptions/app-exception';
import { import {
recordHttpRequestEnd, recordHttpRequestEnd,
recordHttpRequestStart, recordHttpRequestStart,
@@ -49,21 +50,37 @@ async function bootstrap(): Promise<void> {
transformOptions: { enableImplicitConversion: true }, transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors) => { exceptionFactory: (errors) => {
const { normalizedError } = buildValidationErrorResponse(errors); const { normalizedError } = buildValidationErrorResponse(errors);
return new BadRequestException({ return new AppException('VALIDATION_ERROR', {
message: normalizedError.message, 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') .setTitle('External Services Gateway')
.setDescription( .setDescription(
'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' + 'Unified gateway for external inquiry providers with JWT authentication, RBAC, ' +
'per-user inquiry access, audit logging, and normalized inquiry responses.', 'per-user inquiry access, audit logging, and normalized inquiry responses.',
) )
.setVersion('1.0') .setVersion('1.0')
.addServer("http://localhost:8085")
.addServer("http://192.168.20.22:8085");
if (clientUrl) {
documentBuilder.addServer(clientUrl);
}
if (baseUrlProd) {
documentBuilder.addServer(baseUrlProd);
}
const swaggerConfig = documentBuilder
.addBearerAuth( .addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'bearer', 'bearer',

View File

@@ -11,6 +11,12 @@ import { withRetry } from '../../common/helpers/retry.helper';
import { withTimeout } from '../../common/helpers/timeout.helper'; import { withTimeout } from '../../common/helpers/timeout.helper';
import { RequestLogger } from '../../common/helpers/request-logger.helper'; import { RequestLogger } from '../../common/helpers/request-logger.helper';
import { ProviderConfigSlice } from '../interfaces/provider-config.interface'; import { ProviderConfigSlice } from '../interfaces/provider-config.interface';
import {
AppErrorCode,
buildNormalizedError,
isAppErrorCode,
resolveProviderPublicMessage,
} from '../../common/constants/error-messages';
/** /**
* Abstract base for all provider adapters. * Abstract base for all provider adapters.
@@ -121,15 +127,32 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
} }
protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto { protected normalizeError(partial: Partial<NormalizedErrorDto>): NormalizedErrorDto {
return { const code = this.toProviderErrorCode(partial.code);
code: partial.code ?? 'PROVIDER_ERROR', const publicMessage =
message: partial.message ?? 'Provider request failed', code === 'PROVIDER_ERROR'
? resolveProviderPublicMessage(partial.providerMessage ?? partial.message)
: {
message: partial.message ?? 'Provider request failed',
messageFa: partial.messageFa,
};
return buildNormalizedError(code, {
message: publicMessage.message,
messageFa: publicMessage.messageFa,
providerMessage: partial.providerMessage, providerMessage: partial.providerMessage,
providerCode: partial.providerCode, providerCode: partial.providerCode,
providerTrackingCode: partial.providerTrackingCode, providerTrackingCode: partial.providerTrackingCode,
details: partial.details, details: partial.details,
conflict: partial.conflict, conflict: partial.conflict,
}; });
}
private toProviderErrorCode(code?: string): AppErrorCode {
if (!code) return 'PROVIDER_ERROR';
if (isAppErrorCode(code)) return code;
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'PROVIDER_TIMEOUT';
if (code === 'ECONNRESET' || code === 'NETWORK_ERROR') return 'PROVIDER_NETWORK_ERROR';
return 'PROVIDER_ERROR';
} }
protected formatProviderError( protected formatProviderError(
@@ -139,12 +162,12 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>, extras?: Pick<NormalizedErrorDto, 'providerTrackingCode' | 'details' | 'conflict'>,
): Error { ): Error {
const message = providerMessage?.trim() || fallbackMessage; const message = providerMessage?.trim() || fallbackMessage;
const code = providerCode?.trim() || 'PROVIDER_ERROR'; const upstreamCode = providerCode?.trim();
const normalized = this.normalizeError({ const normalized = this.normalizeError({
code, code: upstreamCode,
message, message: fallbackMessage,
providerMessage: message, providerMessage: message,
providerCode: code, providerCode: upstreamCode,
...extras, ...extras,
}); });
const err = new Error(message); const err = new Error(message);
@@ -166,10 +189,23 @@ export abstract class BaseProvider<TRequest = unknown, TResponse = unknown>
} }
} }
return this.formatProviderError( const providerCode = error.code ?? String(error.response?.status ?? 'NETWORK_ERROR');
error.message, const code = error.response ? 'PROVIDER_BAD_RESPONSE' : providerCode;
String(error.response?.status ?? 'NETWORK_ERROR'), 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( protected abstract callProvider(

View File

@@ -8,6 +8,10 @@ import {
import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import {
AppErrorCode,
buildNormalizedError,
} from '../../common/constants/error-messages';
import { AmitisAuthServiceConfig } from '../../config/configuration'; import { AmitisAuthServiceConfig } from '../../config/configuration';
import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenDocument } from '../schemas/general-token.schema';
import { GeneralTokenService } from '../services/general-token.service'; import { GeneralTokenService } from '../services/general-token.service';
@@ -209,14 +213,14 @@ export class AmitisProvider {
loginStatus === 2 loginStatus === 2
? 'AMITIS username or password is invalid for this service account' ? 'AMITIS username or password is invalid for this service account'
: `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`, : `AMITIS login rejected (LoginStatus=${loginStatus ?? 'unknown'})`,
loginStatus === 2 ? 'AUTH_INVALID_CREDENTIALS' : 'AUTH_REJECTED', loginStatus === 2 ? 'PROVIDER_AUTH_FAILED' : 'PROVIDER_REJECTED',
); );
} }
if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) { if (loginStatus !== undefined && loginStatus !== 1 && body.IsSucceed !== true) {
throw this.createAuthError( throw this.createAuthError(
`AMITIS login rejected (LoginStatus=${loginStatus})`, `AMITIS login rejected (LoginStatus=${loginStatus})`,
'AUTH_REJECTED', 'PROVIDER_REJECTED',
); );
} }
@@ -267,13 +271,28 @@ export class AmitisProvider {
return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`; return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`;
} }
private createAuthError(message: string, code: string): Error { private createAuthError(message: string, code: AppErrorCode): Error {
const normalized: NormalizedErrorDto = { const normalized = buildNormalizedError(code, {
code,
message, message,
providerMessage: message, providerMessage: message,
providerCode: code, 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); const err = new Error(message);
(err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized;
return err; return err;
@@ -290,16 +309,17 @@ export class AmitisProvider {
error.response?.data !== undefined error.response?.data !== undefined
? JSON.stringify(error.response.data).slice(0, 500) ? JSON.stringify(error.response.data).slice(0, 500)
: undefined; : undefined;
return new Error( return this.createProviderAuthError(
body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`, body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`,
error,
); );
} }
if (error instanceof Error) { if (error instanceof Error) {
return new Error(`${message}: ${error.message}`); return this.createProviderAuthError(`${message}: ${error.message}`, error);
} }
return new Error(`${message}: ${String(error)}`); return this.createProviderAuthError(`${message}: ${String(error)}`, error);
} }
} }

View File

@@ -6,6 +6,7 @@ import { InquiryType } from '../../common/enums/inquiry-type.enum';
import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderName } from '../../common/enums/provider-name.enum';
import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper'; import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper';
import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { resolveProviderPublicMessage } from '../../common/constants/error-messages';
import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface'; import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface';
import { GeneralTokenService } from '../services/general-token.service'; import { GeneralTokenService } from '../services/general-token.service';
import { CachedInquiryResultService } from '../services/cached-inquiry-result.service'; import { CachedInquiryResultService } from '../services/cached-inquiry-result.service';
@@ -191,19 +192,22 @@ export class TejaratNouProvider implements InquiryProvider<PersonInquiryPayload,
} }
private createProviderError(providerMessage: string, providerCode: string): Error { private createProviderError(providerMessage: string, providerCode: string): Error {
const publicMessage = resolveProviderPublicMessage(providerMessage);
const error = new Error(providerMessage); const error = new Error(providerMessage);
( (
error as Error & { error as Error & {
normalizedError: { normalizedError: {
code: string; code: string;
message: string; message: string;
messageFa: string;
providerMessage: string; providerMessage: string;
providerCode: string; providerCode: string;
}; };
} }
).normalizedError = { ).normalizedError = {
code: 'PROVIDER_ERROR', code: 'PROVIDER_ERROR',
message: providerMessage, message: publicMessage.message,
messageFa: publicMessage.messageFa,
providerMessage, providerMessage,
providerCode, providerCode,
}; };

View File

@@ -5,6 +5,8 @@ import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto';
import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface';
import { InquiryException } from '../../common/exceptions/inquiry.exception'; import { InquiryException } from '../../common/exceptions/inquiry.exception';
import { ProviderFactory } from '../factory/provider.factory'; import { ProviderFactory } from '../factory/provider.factory';
import { translateError } from '../../common/helpers/translate-error.helper';
import { buildNormalizedError } from '../../common/constants/error-messages';
export interface OrchestrationResult<T> { export interface OrchestrationResult<T> {
data: T; data: T;
@@ -30,8 +32,9 @@ export class ProviderOrchestratorService {
if (providers.length === 0) { if (providers.length === 0) {
throw new InquiryException('No providers available for inquiry', { throw new InquiryException('No providers available for inquiry', {
code: 'NO_PROVIDERS', ...buildNormalizedError('NO_PROVIDERS', {
message: `No enabled providers configured for ${inquiryType}`, message: `No enabled providers configured for ${inquiryType}`,
}),
}); });
} }
@@ -77,12 +80,11 @@ export class ProviderOrchestratorService {
const lastProvider = providers[providers.length - 1]!.name; const lastProvider = providers[providers.length - 1]!.name;
throw new InquiryException( throw new InquiryException(
errors.map((error) => error.message).join('; '), errors.map((error) => error.message).join('; '),
{ buildNormalizedError('ALL_PROVIDERS_FAILED', {
code: 'ALL_PROVIDERS_FAILED',
message: errors.map((error) => error.message).join('; '), message: errors.map((error) => error.message).join('; '),
providerMessage: lastError.providerMessage ?? lastError.message, providerMessage: lastError.providerMessage ?? lastError.message,
providerCode: lastError.providerCode ?? lastError.code, providerCode: lastError.providerCode ?? lastError.code,
}, }),
undefined, undefined,
lastProvider, lastProvider,
); );
@@ -90,14 +92,13 @@ export class ProviderOrchestratorService {
private extractError(error: unknown): NormalizedErrorDto { private extractError(error: unknown): NormalizedErrorDto {
if (error && typeof error === 'object' && 'normalizedError' in error) { if (error && typeof error === 'object' && 'normalizedError' in error) {
return (error as { normalizedError: NormalizedErrorDto }).normalizedError; return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError);
} }
if (error instanceof InquiryException) { if (error instanceof InquiryException) {
return error.normalizedError; return translateError(error.normalizedError);
} }
return { return buildNormalizedError('UNKNOWN_ERROR', {
code: 'UNKNOWN_ERROR',
message: error instanceof Error ? error.message : 'Unknown provider error', message: error instanceof Error ? error.message : 'Unknown provider error',
}; });
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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