Merge pull request 'main' (#9) from s.hajizadeh/esg:main into main

Reviewed-on: Shared/esg#9
This commit is contained in:
2026-06-29 17:26:26 +03:30
53 changed files with 1965 additions and 1178 deletions

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)
? message.join(', ')
: String(message ?? exception.message);
const code = resolveErrorCodeFromMessage(normalizedMessage);
return code
? buildNormalizedError(code, { message: normalizedMessage })
: translateError({
code: 'HTTP_ERROR', code: 'HTTP_ERROR',
message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), 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,9 +50,10 @@ 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,
}); });
}, },
}), }),

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 =
code === 'PROVIDER_ERROR'
? resolveProviderPublicMessage(partial.providerMessage ?? partial.message)
: {
message: partial.message ?? 'Provider request failed', 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'],
},
],
});
});
});