From 45ef3964661013d9f8589ebd362fad10323c22e8 Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Mon, 29 Jun 2026 17:25:19 +0330 Subject: [PATCH] feat: add Persian error translations and comprehensive test suite Implement normalized error handling with Persian translations across all exception types, replace legacy NestJS exceptions with AppException, and add unit, integration, and smoke tests. - Add error catalog with gateway-owned codes and Persian messages - Introduce AppException wrapping normalized error envelopes - Add translateError helper for automatic messageFa population - Remove claims module and update provider error normalization - Add unit tests for error contracts and helper functions - Add integration tests for admin and inquiry endpoints - Add smoke tests for real provider connectivity - Add test support utilities (auth mocks, assertions, app factory) --- jest.config.js | 36 ++ nest-cli.json | 2 +- package-lock.json | 165 ++++++++ package.json | 30 +- src/app.module.ts | 2 - src/auth/auth.service.ts | 23 +- src/auth/guards/api-key.guard.ts | 6 +- src/auth/guards/inquiry-access.guard.ts | 5 +- src/auth/guards/jwt-auth.guard.ts | 5 +- src/auth/guards/roles.guard.ts | 5 +- src/auth/strategies/jwt.strategy.ts | 7 +- src/claims/claims.controller.ts | 32 -- src/claims/claims.module.ts | 35 -- .../dto/submit-third-party-claim.dto.ts | 312 --------------- src/claims/fanavaran/fanavaran-api.service.ts | 119 ------ .../fanavaran/fanavaran-auth.service.ts | 141 ------- src/claims/fanavaran/fanavaran.types.ts | 81 ---- .../schemas/evaluation-request.schema.ts | 30 -- src/claims/schemas/field-expert.schema.ts | 18 - .../services/claim-expert-resolver.service.ts | 72 ---- .../services/evaluation-request.service.ts | 19 - src/claims/third-party-claim.service.ts | 159 -------- src/cli/create-super-admin.service.ts | 7 +- src/common/constants/error-messages.ts | 374 ++++++++++++++++++ src/common/dto/base-inquiry-response.dto.ts | 3 + src/common/dto/normalized-error.dto.ts | 3 + src/common/exceptions/app-exception.spec.ts | 94 +++++ src/common/exceptions/app-exception.ts | 34 ++ src/common/filters/all-exceptions.filter.ts | 35 +- src/common/helpers/inquiry-response.helper.ts | 8 +- src/common/helpers/translate-error.helper.ts | 60 +++ src/common/helpers/validation-error.helper.ts | 134 ++++++- src/config/configuration.ts | 35 -- .../dto/generic-inquiry-response.dto.ts | 3 + .../dto/person-inquiry-response.dto.ts | 3 + src/inquiry/inquiry.service.ts | 11 +- src/main.ts | 8 +- src/providers/base/base-provider.abstract.ts | 60 ++- .../implementations/amitis.provider.ts | 38 +- .../implementations/tejaratnou.provider.ts | 6 +- .../strategy/provider-orchestrator.service.ts | 21 +- src/rate-limit/user-rate-limit.guard.ts | 5 +- src/users/users.service.ts | 19 +- test/admin/admin.e2e-spec.ts | 221 +++++++++++ test/integration/inquiry.e2e-spec.ts | 131 ++++++ test/smoke/providers.smoke-spec.ts | 78 ++++ test/support/app.ts | 40 ++ test/support/assertions.ts | 23 ++ test/support/auth.ts | 63 +++ test/support/client-config.factory.ts | 38 ++ test/support/inquiry-endpoints.ts | 79 ++++ test/support/mock-provider.ts | 60 +++ test/unit/helpers-and-mappers.spec.ts | 145 +++++++ 53 files changed, 1965 insertions(+), 1178 deletions(-) create mode 100644 jest.config.js delete mode 100644 src/claims/claims.controller.ts delete mode 100644 src/claims/claims.module.ts delete mode 100644 src/claims/dto/submit-third-party-claim.dto.ts delete mode 100644 src/claims/fanavaran/fanavaran-api.service.ts delete mode 100644 src/claims/fanavaran/fanavaran-auth.service.ts delete mode 100644 src/claims/fanavaran/fanavaran.types.ts delete mode 100644 src/claims/schemas/evaluation-request.schema.ts delete mode 100644 src/claims/schemas/field-expert.schema.ts delete mode 100644 src/claims/services/claim-expert-resolver.service.ts delete mode 100644 src/claims/services/evaluation-request.service.ts delete mode 100644 src/claims/third-party-claim.service.ts create mode 100644 src/common/constants/error-messages.ts create mode 100644 src/common/exceptions/app-exception.spec.ts create mode 100644 src/common/exceptions/app-exception.ts create mode 100644 src/common/helpers/translate-error.helper.ts create mode 100644 test/admin/admin.e2e-spec.ts create mode 100644 test/integration/inquiry.e2e-spec.ts create mode 100644 test/smoke/providers.smoke-spec.ts create mode 100644 test/support/app.ts create mode 100644 test/support/assertions.ts create mode 100644 test/support/auth.ts create mode 100644 test/support/client-config.factory.ts create mode 100644 test/support/inquiry-endpoints.ts create mode 100644 test/support/mock-provider.ts create mode 100644 test/unit/helpers-and-mappers.spec.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4b69031 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,36 @@ +module.exports = { + projects: [ + { + displayName: 'unit', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/src/**/*.spec.ts', '/test/unit/**/*.spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'integration', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/integration/**/*.e2e-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'admin', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/admin/**/*.e2e-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + { + displayName: 'smoke', + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/test/smoke/**/*.smoke-spec.ts'], + moduleFileExtensions: ['js', 'json', 'ts'], + }, + ], +}; diff --git a/nest-cli.json b/nest-cli.json index 03693c8..ea1a21d 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,7 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true, + "deleteOutDir": false, "assets": [{ "include": "public/**/*", "outDir": "dist" }] } } diff --git a/package-lock.json b/package-lock.json index 957c966..63e0167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,12 +53,14 @@ "@types/jest": "^29.5.14", "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "eslint": "^9.17.0", "jest": "^29.7.0", "source-map-support": "^0.5.21", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", @@ -2372,6 +2374,19 @@ "reflect-metadata": "^0.1.13 || ^0.2.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -3816,6 +3831,16 @@ "node": "^18.19.0 || >=20.6.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4066,6 +4091,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/eslint/-/eslint-9.6.1.tgz", @@ -4201,6 +4233,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/ms/-/ms-2.1.0.tgz", @@ -4328,6 +4367,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/@types/tedious/-/tedious-4.0.14.tgz", @@ -5061,6 +5124,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/asynckit/-/asynckit-0.4.0.tgz", @@ -5850,6 +5920,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/concat-map/-/concat-map-0.0.1.tgz", @@ -5925,6 +6005,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6143,6 +6230,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/diff/-/diff-4.0.4.tgz", @@ -7236,6 +7334,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/forwarded/-/forwarded-0.2.0.tgz", @@ -11190,6 +11306,55 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 847dd1c..3858626 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,14 @@ "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", + "test": "jest --selectProjects unit integration admin", + "test:unit": "jest --selectProjects unit", + "test:integration": "jest --selectProjects integration", + "test:admin": "jest --selectProjects admin", + "test:watch": "jest --selectProjects unit integration admin --watch", + "test:cov": "jest --selectProjects unit integration admin --coverage", + "smoke": "ESG_SMOKE_ENABLED=true jest --selectProjects smoke --runInBand", + "smoke:client": "npm run smoke --", "cli:create-super-admin": "ts-node -r tsconfig-paths/register src/cli/create-super-admin.ts" }, "dependencies": { @@ -60,33 +65,18 @@ "@types/jest": "^29.5.14", "@types/node": "^22.19.19", "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "eslint": "^9.17.0", "jest": "^29.7.0", "source-map-support": "^0.5.21", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/src/app.module.ts b/src/app.module.ts index d1acb27..247c375 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,7 +11,6 @@ import { RateLimitModule } from './rate-limit/rate-limit.module'; import { LoggingModule } from './logging/logging.module'; import { ProvidersModule } from './providers/providers.module'; import { InquiryModule } from './inquiry/inquiry.module'; -import { ClaimsModule } from './claims/claims.module'; import { DatabaseSeederModule } from './database/database-seeder.module'; /** @@ -35,7 +34,6 @@ import { DatabaseSeederModule } from './database/database-seeder.module'; DatabaseSeederModule, ProvidersModule, InquiryModule, - ClaimsModule, ], }) export class AppModule implements NestModule { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 311d88f..4833c30 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,4 @@ -import { - ForbiddenException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AuditEvent } from '../common/enums/audit-event.enum'; import { AuditService } from '../audit/audit.service'; import { UsersService, RequestContext } from '../users/users.service'; @@ -14,6 +10,7 @@ import { ProfileDto } from './dto/profile.dto'; import { UserMapper } from '../users/mappers/user.mapper'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { AuthenticatedUser } from './interfaces/authenticated-user.interface'; +import { AppException } from '../common/exceptions/app-exception'; @Injectable() export class AuthService { @@ -34,23 +31,23 @@ export class AuthService { userAgent: context.userAgent, metadata: { reason: 'invalid_credentials' }, }); - throw new UnauthorizedException('Invalid credentials'); + throw new AppException('INVALID_CREDENTIALS'); } if (!user.isActive) { await this.auditFailedLogin(user._id.toString(), user.username, 'inactive', context); - throw new ForbiddenException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { await this.auditFailedLogin(user._id.toString(), user.username, 'blocked', context); - throw new ForbiddenException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } const valid = await this.passwordService.compare(dto.password, user.password); if (!valid) { await this.auditFailedLogin(user._id.toString(), user.username, 'invalid_credentials', context); - throw new UnauthorizedException('Invalid credentials'); + throw new AppException('INVALID_CREDENTIALS'); } const tokens = await this.issueTokens(user); @@ -72,20 +69,20 @@ export class AuthService { try { payload = this.tokenService.verifyRefreshToken(refreshToken); } catch { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException('INVALID_REFRESH_TOKEN'); } const valid = await this.usersService.validateRefreshToken(payload.sub, refreshToken); if (!valid) { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException('INVALID_REFRESH_TOKEN'); } const user = await this.usersService.findById(payload.sub); if (!user.isActive) { - throw new ForbiddenException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { - throw new ForbiddenException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } const tokens = await this.issueTokens(user); diff --git a/src/auth/guards/api-key.guard.ts b/src/auth/guards/api-key.guard.ts index 97160a3..f0942e0 100644 --- a/src/auth/guards/api-key.guard.ts +++ b/src/auth/guards/api-key.guard.ts @@ -2,11 +2,11 @@ import { CanActivate, ExecutionContext, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; import { API_KEY_HEADER } from '../../common/constants/app.constants'; +import { AppException } from '../../common/exceptions/app-exception'; /** * API key authentication for inquiry endpoints. @@ -22,11 +22,11 @@ export class ApiKeyGuard implements CanActivate { const expected = this.configService.get('auth.apiKey'); if (!expected) { - throw new UnauthorizedException('API key authentication is not configured'); + throw new AppException('API_KEY_NOT_CONFIGURED'); } if (!apiKey || apiKey !== expected) { - throw new UnauthorizedException('Invalid or missing API key'); + throw new AppException('INVALID_API_KEY'); } return true; diff --git a/src/auth/guards/inquiry-access.guard.ts b/src/auth/guards/inquiry-access.guard.ts index 69315f1..cfb9008 100644 --- a/src/auth/guards/inquiry-access.guard.ts +++ b/src/auth/guards/inquiry-access.guard.ts @@ -1,10 +1,11 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { ADMIN_ROLES } from '../../common/enums/role.enum'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { INQUIRY_ACCESS_KEY } from '../constants/auth.constants'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Validates that the user may call a specific inquiry endpoint. @@ -28,7 +29,7 @@ export class InquiryAccessGuard implements CanActivate { const user = request.user; if (!user) { - throw new ForbiddenException('Authentication required'); + throw new AppException('AUTHENTICATION_REQUIRED'); } if (ADMIN_ROLES.includes(user.role)) { diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts index c218331..6f430e8 100644 --- a/src/auth/guards/jwt-auth.guard.ts +++ b/src/auth/guards/jwt-auth.guard.ts @@ -1,6 +1,7 @@ -import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Protects routes with Passport JWT access strategy. @@ -14,7 +15,7 @@ export class JwtAuthGuard extends AuthGuard(JWT_ACCESS_STRATEGY) { _context: ExecutionContext, ): TUser { if (err || !user) { - throw err ?? new UnauthorizedException('Unauthorized'); + throw err ?? new AppException('UNAUTHORIZED'); } return user; } diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index a00a8f2..e3937fa 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -1,9 +1,10 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { ROLES_KEY } from '../constants/auth.constants'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { Role } from '../../common/enums/role.enum'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Enforces @Roles() metadata against the authenticated user's role. @@ -26,7 +27,7 @@ export class RolesGuard implements CanActivate { const user = request.user; if (!user || !requiredRoles.includes(user.role)) { - throw new ForbiddenException('Insufficient role permissions'); + throw new AppException('INSUFFICIENT_ROLE'); } return true; diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 2a1587a..6117453 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; @@ -6,6 +6,7 @@ import { JWT_ACCESS_STRATEGY } from '../constants/auth.constants'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { AuthenticatedUser } from '../interfaces/authenticated-user.interface'; import { UsersService } from '../../users/users.service'; +import { AppException } from '../../common/exceptions/app-exception'; /** * Validates access JWT and attaches a slim user object to the request. @@ -27,10 +28,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_ACCESS_STRATEGY) const user = await this.usersService.findById(payload.sub); if (!user.isActive) { - throw new UnauthorizedException('Account is inactive'); + throw new AppException('ACCOUNT_INACTIVE'); } if (user.isBlocked) { - throw new UnauthorizedException('Account is blocked'); + throw new AppException('ACCOUNT_BLOCKED'); } return { diff --git a/src/claims/claims.controller.ts b/src/claims/claims.controller.ts deleted file mode 100644 index 8426c13..0000000 --- a/src/claims/claims.controller.ts +++ /dev/null @@ -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 { - return this.thirdPartyClaimService.submit(dto); - } -} diff --git a/src/claims/claims.module.ts b/src/claims/claims.module.ts deleted file mode 100644 index 7b395fa..0000000 --- a/src/claims/claims.module.ts +++ /dev/null @@ -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 {} diff --git a/src/claims/dto/submit-third-party-claim.dto.ts b/src/claims/dto/submit-third-party-claim.dto.ts deleted file mode 100644 index a61e38c..0000000 --- a/src/claims/dto/submit-third-party-claim.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/claims/fanavaran/fanavaran-api.service.ts b/src/claims/fanavaran/fanavaran-api.service.ts deleted file mode 100644 index add506f..0000000 --- a/src/claims/fanavaran/fanavaran-api.service.ts +++ /dev/null @@ -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('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 { - const authenticationToken = await this.authService.getAuthenticationToken(); - const response = await this.httpClient.get( - '/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 { - 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 { - 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}`, - ); - } -} diff --git a/src/claims/fanavaran/fanavaran-auth.service.ts b/src/claims/fanavaran/fanavaran-auth.service.ts deleted file mode 100644 index 4897b3c..0000000 --- a/src/claims/fanavaran/fanavaran-auth.service.ts +++ /dev/null @@ -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('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 { - 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 { - 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 { - 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, - 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; - } -} diff --git a/src/claims/fanavaran/fanavaran.types.ts b/src/claims/fanavaran/fanavaran.types.ts deleted file mode 100644 index 49c1a9f..0000000 --- a/src/claims/fanavaran/fanavaran.types.ts +++ /dev/null @@ -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; -} diff --git a/src/claims/schemas/evaluation-request.schema.ts b/src/claims/schemas/evaluation-request.schema.ts deleted file mode 100644 index e724a2c..0000000 --- a/src/claims/schemas/evaluation-request.schema.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Types } from 'mongoose'; - -export type EvaluationRequestDocument = HydratedDocument; - -@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); diff --git a/src/claims/schemas/field-expert.schema.ts b/src/claims/schemas/field-expert.schema.ts deleted file mode 100644 index 1346104..0000000 --- a/src/claims/schemas/field-expert.schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Types } from 'mongoose'; - -export type FieldExpertDocument = HydratedDocument; - -@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); diff --git a/src/claims/services/claim-expert-resolver.service.ts b/src/claims/services/claim-expert-resolver.service.ts deleted file mode 100644 index 96a9ef9..0000000 --- a/src/claims/services/claim-expert-resolver.service.ts +++ /dev/null @@ -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, - private readonly evaluationRequestService: EvaluationRequestService, - ) { - this.config = configService.get('fanavaran')!; - } - - async resolveClaimExpertId(input: ClaimExpertResolutionInput): Promise { - 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 { - 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; - } -} diff --git a/src/claims/services/evaluation-request.service.ts b/src/claims/services/evaluation-request.service.ts deleted file mode 100644 index 45bde64..0000000 --- a/src/claims/services/evaluation-request.service.ts +++ /dev/null @@ -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, - ) {} - - async findByRequestId(requestId: string): Promise { - return this.evaluationRequestModel.findOne({ requestId }).exec(); - } -} diff --git a/src/claims/third-party-claim.service.ts b/src/claims/third-party-claim.service.ts deleted file mode 100644 index b1df139..0000000 --- a/src/claims/third-party-claim.service.ts +++ /dev/null @@ -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 { - 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, - }; - } -} diff --git a/src/cli/create-super-admin.service.ts b/src/cli/create-super-admin.service.ts index 9adc261..4666393 100644 --- a/src/cli/create-super-admin.service.ts +++ b/src/cli/create-super-admin.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { randomBytes } from 'crypto'; @@ -8,6 +8,7 @@ import { PasswordService } from '../auth/services/password.service'; import { User, UserDocument } from '../users/schemas/user.schema'; import { UserMapper } from '../users/mappers/user.mapper'; import { UserResponseDto } from '../users/dto/user-response.dto'; +import { AppException } from '../common/exceptions/app-exception'; export interface CreateSuperAdminInput { fullName: string; @@ -27,7 +28,7 @@ export class CreateSuperAdminService { async create(input: CreateSuperAdminInput): Promise { if (input.password.length < 8) { - throw new ConflictException('Password must be at least 8 characters'); + throw new AppException('PASSWORD_TOO_SHORT'); } const username = input.username.toLowerCase().trim(); @@ -35,7 +36,7 @@ export class CreateSuperAdminService { const existing = await this.userModel.findOne({ $or: [{ username }, { email }] }); if (existing) { - throw new ConflictException('Username or email already exists'); + throw new AppException('USERNAME_OR_EMAIL_EXISTS'); } const superAdminExists = await this.userModel.exists({ role: Role.SUPER_ADMIN }); diff --git a/src/common/constants/error-messages.ts b/src/common/constants/error-messages.ts new file mode 100644 index 0000000..496e830 --- /dev/null +++ b/src/common/constants/error-messages.ts @@ -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; + +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> = {}, +): 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 | 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 = { + '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 = { + '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, + }; +} diff --git a/src/common/dto/base-inquiry-response.dto.ts b/src/common/dto/base-inquiry-response.dto.ts index 3684e2f..cf6ed11 100644 --- a/src/common/dto/base-inquiry-response.dto.ts +++ b/src/common/dto/base-inquiry-response.dto.ts @@ -18,6 +18,9 @@ export class BaseInquiryResponseDto> { @ApiPropertyOptional({ example: 'Inquiry completed successfully' }) message?: string; + @ApiPropertyOptional({ example: 'استعلام با موفقیت انجام شد' }) + messageFa?: string; + @ApiPropertyOptional({ nullable: true }) data?: T | null; diff --git a/src/common/dto/normalized-error.dto.ts b/src/common/dto/normalized-error.dto.ts index 2b2b9b9..b708340 100644 --- a/src/common/dto/normalized-error.dto.ts +++ b/src/common/dto/normalized-error.dto.ts @@ -11,6 +11,9 @@ export class NormalizedErrorDto { @ApiProperty({ example: 'Provider request timed out' }) message!: string; + @ApiPropertyOptional({ example: 'درخواست به سرویسدهنده زمانبر شد' }) + messageFa?: string; + @ApiPropertyOptional({ example: 'سرویس در دسترس نیست' }) providerMessage?: string; diff --git a/src/common/exceptions/app-exception.spec.ts b/src/common/exceptions/app-exception.spec.ts new file mode 100644 index 0000000..1926c76 --- /dev/null +++ b/src/common/exceptions/app-exception.spec.ts @@ -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', + }); + }); +}); diff --git a/src/common/exceptions/app-exception.ts b/src/common/exceptions/app-exception.ts new file mode 100644 index 0000000..2f5f759 --- /dev/null +++ b/src/common/exceptions/app-exception.ts @@ -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; + } +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index c87a744..37a1e09 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -11,6 +11,12 @@ import { NormalizedErrorDto } from '../dto/normalized-error.dto'; import { buildInquiryResponse } from '../helpers/inquiry-response.helper'; import { ProviderException } from '../exceptions/provider.exception'; import { isNormalizedError } from '../helpers/validation-error.helper'; +import { translateError } from '../helpers/translate-error.helper'; +import { AppException } from '../exceptions/app-exception'; +import { + buildNormalizedError, + resolveErrorCodeFromMessage, +} from '../constants/error-messages'; /** * Global exception filter — normalizes all errors into BaseInquiryResponseDto @@ -52,20 +58,24 @@ export class AllExceptionsFilter implements ExceptionFilter { } private extractNormalizedError(exception: unknown): NormalizedErrorDto { - if (exception instanceof ProviderException) { + if (exception instanceof AppException) { return exception.normalizedError; } + if (exception instanceof ProviderException) { + return translateError(exception.normalizedError); + } + if (exception instanceof HttpException) { const res = exception.getResponse(); if (typeof res === 'object' && res !== null) { if ('error' in res && isNormalizedError((res as { error: unknown }).error)) { - return (res as { error: NormalizedErrorDto }).error; + return translateError((res as { error: NormalizedErrorDto }).error); } if (isNormalizedError(res)) { - return res; + return translateError(res); } } @@ -74,15 +84,22 @@ export class AllExceptionsFilter implements ExceptionFilter { ? res : (res as { message?: string | string[] }).message; - return { - code: 'HTTP_ERROR', - message: Array.isArray(message) ? message.join(', ') : String(message ?? exception.message), - }; + const normalizedMessage = Array.isArray(message) + ? message.join(', ') + : String(message ?? exception.message); + const code = resolveErrorCodeFromMessage(normalizedMessage); + + return code + ? buildNormalizedError(code, { message: normalizedMessage }) + : translateError({ + code: 'HTTP_ERROR', + message: normalizedMessage, + }); } - return { + return translateError({ code: 'INTERNAL_ERROR', message: exception instanceof Error ? exception.message : 'Unexpected error', - }; + }); } } diff --git a/src/common/helpers/inquiry-response.helper.ts b/src/common/helpers/inquiry-response.helper.ts index 41db395..af2a7c1 100644 --- a/src/common/helpers/inquiry-response.helper.ts +++ b/src/common/helpers/inquiry-response.helper.ts @@ -1,5 +1,6 @@ import { BaseInquiryResponseDto } from '../dto/base-inquiry-response.dto'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { translateError } from './translate-error.helper'; export function buildInquiryResponse>(params: { success: boolean; @@ -10,14 +11,17 @@ export function buildInquiryResponse>(params: { data?: T | null; error?: NormalizedErrorDto | null; }): BaseInquiryResponseDto { + const error = params.success ? null : (params.error != null ? translateError(params.error) : null); + return { success: params.success, provider: params.provider, trackingCode: params.trackingCode, - message: params.message, + message: params.success ? params.message : undefined, + messageFa: undefined, duration: params.duration, data: params.success ? (params.data ?? null) : null, - error: params.success ? null : (params.error ?? null), + error, }; } diff --git a/src/common/helpers/translate-error.helper.ts b/src/common/helpers/translate-error.helper.ts new file mode 100644 index 0000000..5e74da4 --- /dev/null +++ b/src/common/helpers/translate-error.helper.ts @@ -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 = {}; + 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; +} diff --git a/src/common/helpers/validation-error.helper.ts b/src/common/helpers/validation-error.helper.ts index 08d802f..a403272 100644 --- a/src/common/helpers/validation-error.helper.ts +++ b/src/common/helpers/validation-error.helper.ts @@ -1,11 +1,126 @@ import { ValidationError } from 'class-validator'; import { NormalizedErrorDto } from '../dto/normalized-error.dto'; +import { buildNormalizedError } from '../constants/error-messages'; export interface ValidationFieldError { field: string; constraints: string[]; } +const FIELD_LABELS_FA: Record = { + 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 { + for (const key of CONSTRAINT_PRIORITY) { + if (constraints[key]) { + return constraints[key]; + } + } + + return Object.values(constraints)[0] ?? 'Invalid value'; +} + +function fieldLabelFa(field: string): string { + return FIELD_LABELS_FA[field] ?? field; +} + +function translateValidationConstraint(field: string, constraint: string): string { + const label = fieldLabelFa(field); + + if (constraint.includes('must be exactly 10 digits')) { + return `${label} باید دقیقا ۱۰ رقم باشد`; + } + + if (constraint.includes('birthDate must be in YYYY-MM-DD format')) { + return 'تاریخ تولد باید با فرمت YYYY-MM-DD باشد'; + } + + if (constraint.includes('birthDate must be in YYYY-MM-DD or YYYYMMDD format')) { + return 'تاریخ تولد باید با فرمت YYYY-MM-DD یا YYYYMMDD باشد'; + } + + if (constraint.includes('mobileNo must be an Iranian mobile number')) { + return 'شماره موبایل باید معتبر و با 09 شروع شود'; + } + + if (constraint.includes('sheba must start with IR and contain 24 digits')) { + return 'شماره شبا باید با IR شروع شود و ۲۴ رقم داشته باشد'; + } + + const exactDigits = constraint.match(/must be exactly (\d+) digits/); + if (exactDigits?.[1]) { + return `${label} باید دقیقا ${exactDigits[1]} رقم باشد`; + } + + const minLength = constraint.match(/must be longer than or equal to (\d+) characters/); + if (minLength?.[1]) { + return `${label} باید حداقل ${minLength[1]} کاراکتر باشد`; + } + + if (constraint.includes('must be an email')) { + return 'ایمیل باید معتبر باشد'; + } + + if (constraint.includes('must be one of the following values')) { + return `${label} مقدار معتبری ندارد`; + } + + if (constraint.includes('should not be empty')) { + return `${label} الزامی است`; + } + + if (constraint.includes('must be a string')) { + return `${label} باید متن باشد`; + } + + if (constraint.includes('must be an integer number')) { + return `${label} باید عدد صحیح باشد`; + } + + if (constraint.includes('must be a boolean value')) { + return `${label} باید مقدار درست یا نادرست باشد`; + } + + if (constraint.includes('must be an array')) { + return `${label} باید آرایه باشد`; + } + + return `${label} معتبر نیست`; +} + export function flattenValidationErrors( errors: ValidationError[], parentPath = '', @@ -18,7 +133,7 @@ export function flattenValidationErrors( if (error.constraints) { result.push({ field, - constraints: Object.values(error.constraints), + constraints: [pickConstraint(error.constraints)], }); } @@ -34,18 +149,19 @@ export function buildValidationErrorResponse(errors: ValidationError[]): { normalizedError: NormalizedErrorDto; details: ValidationFieldError[]; } { - const details = flattenValidationErrors(errors); - const summary = - details.length > 0 - ? details.map((d) => `${d.field}: ${d.constraints.join(', ')}`).join('; ') - : 'Request validation failed'; + const details = flattenValidationErrors(errors).slice(0, 1); + const selected = details[0]; + const summary = selected?.constraints[0] ?? 'Request validation failed'; + const messageFa = selected + ? translateValidationConstraint(selected.field, selected.constraints[0] ?? '') + : 'اطلاعات ورودی نامعتبر است'; return { - normalizedError: { - code: 'VALIDATION_ERROR', + normalizedError: buildNormalizedError('VALIDATION_ERROR', { message: summary, + messageFa, details, - }, + }), details, }; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 8e179ae..78d38ca 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -27,23 +27,6 @@ export interface AmitisAuthServiceConfig { 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 { enabled: boolean; timeout: number; @@ -98,24 +81,6 @@ export default () => ({ hamta: buildProviderConfig('HAMTA'), moallem: buildProviderConfig('MOALLEM'), 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: { enabled: process.env.TEJARATNOU_ENABLED !== 'false', timeout: parseInt(process.env.TEJARATNOU_TIMEOUT ?? '15000', 10), diff --git a/src/inquiry/dto/generic-inquiry-response.dto.ts b/src/inquiry/dto/generic-inquiry-response.dto.ts index 3fc6579..98b59af 100644 --- a/src/inquiry/dto/generic-inquiry-response.dto.ts +++ b/src/inquiry/dto/generic-inquiry-response.dto.ts @@ -14,6 +14,9 @@ export class GenericInquiryResponseDto { @ApiPropertyOptional() message?: string; + @ApiPropertyOptional() + messageFa?: string; + @ApiPropertyOptional({ type: Object }) data?: Record; diff --git a/src/inquiry/dto/person-inquiry-response.dto.ts b/src/inquiry/dto/person-inquiry-response.dto.ts index de034be..67f8021 100644 --- a/src/inquiry/dto/person-inquiry-response.dto.ts +++ b/src/inquiry/dto/person-inquiry-response.dto.ts @@ -16,6 +16,9 @@ export class PersonInquiryResponseDto { @ApiPropertyOptional() message?: string; + @ApiPropertyOptional() + messageFa?: string; + @ApiPropertyOptional({ type: PersonInquiryDataDto }) data?: PersonInquiryDataDto; diff --git a/src/inquiry/inquiry.service.ts b/src/inquiry/inquiry.service.ts index f5186e9..bceaf38 100644 --- a/src/inquiry/inquiry.service.ts +++ b/src/inquiry/inquiry.service.ts @@ -8,6 +8,8 @@ import { generateTrackingCode } from '../common/helpers/tracking-code.helper'; import { buildInquiryResponse } from '../common/helpers/inquiry-response.helper'; import { InquiryException } from '../common/exceptions/inquiry.exception'; import { InquiryLogService } from '../logging/inquiry-log.service'; +import { translateError } from '../common/helpers/translate-error.helper'; +import { buildNormalizedError } from '../common/constants/error-messages'; import { LegacyInquiryPayload, PersonInquiryPayload, @@ -159,18 +161,17 @@ export class InquiryService { provider?: string; } { 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) { return { - normalized: (error as { normalizedError: NormalizedErrorDto }).normalizedError, + normalized: translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError), }; } return { - normalized: { - code: 'INQUIRY_FAILED', + normalized: buildNormalizedError('INQUIRY_FAILED', { message: error instanceof Error ? error.message : 'Inquiry failed', - }, + }), }; } diff --git a/src/main.ts b/src/main.ts index 0198586..e0f00ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import './telemetry'; -import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -8,6 +8,7 @@ import { AppModule } from './app.module'; import { API_KEY_HEADER } from './common/constants/app.constants'; import { OpenTelemetryNestLogger } from './otel-nest-logger'; import { buildValidationErrorResponse } from './common/helpers/validation-error.helper'; +import { AppException } from './common/exceptions/app-exception'; import { recordHttpRequestEnd, recordHttpRequestStart, @@ -49,9 +50,10 @@ async function bootstrap(): Promise { transformOptions: { enableImplicitConversion: true }, exceptionFactory: (errors) => { const { normalizedError } = buildValidationErrorResponse(errors); - return new BadRequestException({ + return new AppException('VALIDATION_ERROR', { message: normalizedError.message, - error: normalizedError, + messageFa: normalizedError.messageFa, + details: normalizedError.details, }); }, }), diff --git a/src/providers/base/base-provider.abstract.ts b/src/providers/base/base-provider.abstract.ts index 2ae22ba..dfa60e1 100644 --- a/src/providers/base/base-provider.abstract.ts +++ b/src/providers/base/base-provider.abstract.ts @@ -11,6 +11,12 @@ import { withRetry } from '../../common/helpers/retry.helper'; import { withTimeout } from '../../common/helpers/timeout.helper'; import { RequestLogger } from '../../common/helpers/request-logger.helper'; import { ProviderConfigSlice } from '../interfaces/provider-config.interface'; +import { + AppErrorCode, + buildNormalizedError, + isAppErrorCode, + resolveProviderPublicMessage, +} from '../../common/constants/error-messages'; /** * Abstract base for all provider adapters. @@ -121,15 +127,32 @@ export abstract class BaseProvider } protected normalizeError(partial: Partial): NormalizedErrorDto { - return { - code: partial.code ?? 'PROVIDER_ERROR', - message: partial.message ?? 'Provider request failed', + const code = this.toProviderErrorCode(partial.code); + const publicMessage = + code === 'PROVIDER_ERROR' + ? resolveProviderPublicMessage(partial.providerMessage ?? partial.message) + : { + message: partial.message ?? 'Provider request failed', + messageFa: partial.messageFa, + }; + + return buildNormalizedError(code, { + message: publicMessage.message, + messageFa: publicMessage.messageFa, providerMessage: partial.providerMessage, providerCode: partial.providerCode, providerTrackingCode: partial.providerTrackingCode, details: partial.details, conflict: partial.conflict, - }; + }); + } + + private toProviderErrorCode(code?: string): AppErrorCode { + if (!code) return 'PROVIDER_ERROR'; + if (isAppErrorCode(code)) return code; + if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'PROVIDER_TIMEOUT'; + if (code === 'ECONNRESET' || code === 'NETWORK_ERROR') return 'PROVIDER_NETWORK_ERROR'; + return 'PROVIDER_ERROR'; } protected formatProviderError( @@ -139,12 +162,12 @@ export abstract class BaseProvider extras?: Pick, ): Error { const message = providerMessage?.trim() || fallbackMessage; - const code = providerCode?.trim() || 'PROVIDER_ERROR'; + const upstreamCode = providerCode?.trim(); const normalized = this.normalizeError({ - code, - message, + code: upstreamCode, + message: fallbackMessage, providerMessage: message, - providerCode: code, + providerCode: upstreamCode, ...extras, }); const err = new Error(message); @@ -166,10 +189,23 @@ export abstract class BaseProvider } } - return this.formatProviderError( - error.message, - String(error.response?.status ?? 'NETWORK_ERROR'), - ); + const providerCode = error.code ?? String(error.response?.status ?? 'NETWORK_ERROR'); + const code = error.response ? 'PROVIDER_BAD_RESPONSE' : providerCode; + const normalized = this.normalizeError({ + code, + message: error.message, + providerMessage: error.message, + providerCode, + details: [ + { + field: 'providerStatus', + constraints: [String(error.response?.status ?? providerCode)], + }, + ], + }); + const err = new Error(error.message); + (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; + return err; } protected abstract callProvider( diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index 3a6284e..0c9a8a9 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -8,6 +8,10 @@ import { import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; +import { + AppErrorCode, + buildNormalizedError, +} from '../../common/constants/error-messages'; import { AmitisAuthServiceConfig } from '../../config/configuration'; import { GeneralTokenDocument } from '../schemas/general-token.schema'; import { GeneralTokenService } from '../services/general-token.service'; @@ -209,14 +213,14 @@ export class AmitisProvider { loginStatus === 2 ? 'AMITIS username or password is invalid for this service account' : `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) { throw this.createAuthError( `AMITIS login rejected (LoginStatus=${loginStatus})`, - 'AUTH_REJECTED', + 'PROVIDER_REJECTED', ); } @@ -267,13 +271,28 @@ export class AmitisProvider { return `${ProviderName.AMITIS}:${providerName}:${inquiryType}`; } - private createAuthError(message: string, code: string): Error { - const normalized: NormalizedErrorDto = { - code, + private createAuthError(message: string, code: AppErrorCode): Error { + const normalized = buildNormalizedError(code, { message, providerMessage: message, providerCode: code, - }; + }); + const err = new Error(message); + (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; + return err; + } + + private createProviderAuthError(message: string, error: unknown): Error { + const providerCode = + error instanceof AxiosError + ? String(error.response?.status ?? error.code ?? 'NETWORK_ERROR') + : undefined; + const providerMessage = error instanceof Error ? error.message : String(error); + const normalized = buildNormalizedError('PROVIDER_AUTH_FAILED', { + message, + providerMessage, + providerCode, + }); const err = new Error(message); (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; return err; @@ -290,16 +309,17 @@ export class AmitisProvider { error.response?.data !== undefined ? JSON.stringify(error.response.data).slice(0, 500) : undefined; - return new Error( + return this.createProviderAuthError( body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`, + error, ); } if (error instanceof Error) { - return new Error(`${message}: ${error.message}`); + return this.createProviderAuthError(`${message}: ${error.message}`, error); } - return new Error(`${message}: ${String(error)}`); + return this.createProviderAuthError(`${message}: ${String(error)}`, error); } } diff --git a/src/providers/implementations/tejaratnou.provider.ts b/src/providers/implementations/tejaratnou.provider.ts index 61dd738..2773b16 100644 --- a/src/providers/implementations/tejaratnou.provider.ts +++ b/src/providers/implementations/tejaratnou.provider.ts @@ -6,6 +6,7 @@ import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { jalaliDateToGregorianDate } from '../../common/helpers/jalali-date.helper'; import { InquiryProvider, ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; +import { resolveProviderPublicMessage } from '../../common/constants/error-messages'; import { TejaratNouConfig } from '../interfaces/tejaratnou-config.interface'; import { GeneralTokenService } from '../services/general-token.service'; import { CachedInquiryResultService } from '../services/cached-inquiry-result.service'; @@ -191,19 +192,22 @@ export class TejaratNouProvider implements InquiryProvider { data: T; @@ -30,8 +32,9 @@ export class ProviderOrchestratorService { if (providers.length === 0) { throw new InquiryException('No providers available for inquiry', { - code: 'NO_PROVIDERS', - message: `No enabled providers configured for ${inquiryType}`, + ...buildNormalizedError('NO_PROVIDERS', { + message: `No enabled providers configured for ${inquiryType}`, + }), }); } @@ -77,12 +80,11 @@ export class ProviderOrchestratorService { const lastProvider = providers[providers.length - 1]!.name; throw new InquiryException( errors.map((error) => error.message).join('; '), - { - code: 'ALL_PROVIDERS_FAILED', + buildNormalizedError('ALL_PROVIDERS_FAILED', { message: errors.map((error) => error.message).join('; '), providerMessage: lastError.providerMessage ?? lastError.message, providerCode: lastError.providerCode ?? lastError.code, - }, + }), undefined, lastProvider, ); @@ -90,14 +92,13 @@ export class ProviderOrchestratorService { private extractError(error: unknown): NormalizedErrorDto { if (error && typeof error === 'object' && 'normalizedError' in error) { - return (error as { normalizedError: NormalizedErrorDto }).normalizedError; + return translateError((error as { normalizedError: NormalizedErrorDto }).normalizedError); } if (error instanceof InquiryException) { - return error.normalizedError; + return translateError(error.normalizedError); } - return { - code: 'UNKNOWN_ERROR', + return buildNormalizedError('UNKNOWN_ERROR', { message: error instanceof Error ? error.message : 'Unknown provider error', - }; + }); } } diff --git a/src/rate-limit/user-rate-limit.guard.ts b/src/rate-limit/user-rate-limit.guard.ts index 290ce2b..6e6d4f6 100644 --- a/src/rate-limit/user-rate-limit.guard.ts +++ b/src/rate-limit/user-rate-limit.guard.ts @@ -1,14 +1,13 @@ import { CanActivate, ExecutionContext, - HttpException, - HttpStatus, Injectable, } from '@nestjs/common'; import { Request } from 'express'; import { AuthenticatedUser } from '../auth/interfaces/authenticated-user.interface'; import { UsersService } from '../users/users.service'; import { UserRateLimitService } from './user-rate-limit.service'; +import { AppException } from '../common/exceptions/app-exception'; /** * Per-user rate limits from the user document (requestLimitPerMinute / requestLimitPerDay). @@ -36,7 +35,7 @@ export class UserRateLimitGuard implements CanActivate { ); if (!allowed) { - throw new HttpException('User rate limit exceeded', HttpStatus.TOO_MANY_REQUESTS); + throw new AppException('RATE_LIMIT_EXCEEDED'); } void this.usersService.incrementTotalRequests(user.id); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 086f31f..65e0dfe 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,9 +1,5 @@ import { - BadRequestException, - ConflictException, - ForbiddenException, Injectable, - NotFoundException, } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; @@ -19,6 +15,7 @@ import { UpdateUserDto } from './dto/update-user.dto'; import { UserMapper } from './mappers/user.mapper'; import { User, UserDocument } from './schemas/user.schema'; import { UserResponseDto } from './dto/user-response.dto'; +import { AppException } from '../common/exceptions/app-exception'; export interface RequestContext { ip?: string; @@ -36,7 +33,7 @@ export class UsersService { async findById(id: string): Promise { const user = await this.userModel.findById(id); if (!user) { - throw new NotFoundException('User not found'); + throw new AppException('USER_NOT_FOUND'); } return user; } @@ -68,7 +65,7 @@ export class UsersService { $or: [{ username }, { email }], }); if (existing) { - throw new ConflictException('Username or email already exists'); + throw new AppException('USERNAME_OR_EMAIL_EXISTS'); } const passwordHash = await this.passwordService.hash(dto.password); @@ -117,7 +114,7 @@ export class UsersService { const email = dto.email.toLowerCase().trim(); const conflict = await this.userModel.findOne({ email, _id: { $ne: id } }); if (conflict) { - throw new ConflictException('Email already in use'); + throw new AppException('EMAIL_ALREADY_IN_USE'); } user.email = email; } @@ -149,7 +146,7 @@ export class UsersService { ): Promise { const user = await this.findById(id); if (user._id.toString() === actor.id) { - throw new BadRequestException('Cannot block your own account'); + throw new AppException('CANNOT_BLOCK_SELF'); } user.isBlocked = true; user.refreshToken = undefined; @@ -194,7 +191,7 @@ export class UsersService { ): Promise { const user = await this.userModel.findById(id).select('+password +refreshToken'); if (!user) { - throw new NotFoundException('User not found'); + throw new AppException('USER_NOT_FOUND'); } user.password = await this.passwordService.hash(dto.newPassword); @@ -238,10 +235,10 @@ export class UsersService { private assertCanAssignRole(actorRole: Role, targetRole: Role): void { if (targetRole === Role.SUPER_ADMIN && actorRole !== Role.SUPER_ADMIN) { - throw new ForbiddenException('Only SUPER_ADMIN can assign SUPER_ADMIN role'); + throw new AppException('SUPER_ADMIN_ROLE_REQUIRED'); } if (!ADMIN_ROLES.includes(actorRole)) { - throw new ForbiddenException('Insufficient permissions to manage users'); + throw new AppException('INSUFFICIENT_USER_MANAGEMENT_PERMISSION'); } } diff --git a/test/admin/admin.e2e-spec.ts b/test/admin/admin.e2e-spec.ts new file mode 100644 index 0000000..dbbac4f --- /dev/null +++ b/test/admin/admin.e2e-spec.ts @@ -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 = {}) { + 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'); +}); diff --git a/test/integration/inquiry.e2e-spec.ts b/test/integration/inquiry.e2e-spec.ts new file mode 100644 index 0000000..43aab9b --- /dev/null +++ b/test/integration/inquiry.e2e-spec.ts @@ -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); + }); + }); + }); +}); diff --git a/test/smoke/providers.smoke-spec.ts b/test/smoke/providers.smoke-spec.ts new file mode 100644 index 0000000..fd57a7b --- /dev/null +++ b/test/smoke/providers.smoke-spec.ts @@ -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; + 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; + 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((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), + }); + }); +}); diff --git a/test/support/app.ts b/test/support/app.ts new file mode 100644 index 0000000..afa1417 --- /dev/null +++ b/test/support/app.ts @@ -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 { + 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; +} diff --git a/test/support/assertions.ts b/test/support/assertions.ts new file mode 100644 index 0000000..b57a439 --- /dev/null +++ b/test/support/assertions.ts @@ -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), + }, + }); +} diff --git a/test/support/auth.ts b/test/support/auth.ts new file mode 100644 index 0000000..5fcaf77 --- /dev/null +++ b/test/support/auth.ts @@ -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 { + 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; 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; + }, + }; +} diff --git a/test/support/client-config.factory.ts b/test/support/client-config.factory.ts new file mode 100644 index 0000000..e701015 --- /dev/null +++ b/test/support/client-config.factory.ts @@ -0,0 +1,38 @@ +import { InquiryType } from '../../src/common/enums/inquiry-type.enum'; + +export interface TestClientConfig { + clientId: string; + apiKey: string; + providerBaseUrl: string; + credentials: Record; + requiresVpn: boolean; + allowedInquiries: InquiryType[]; +} + +export function createClientConfig( + overrides: Partial = {}, +): 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; +} diff --git a/test/support/inquiry-endpoints.ts b/test/support/inquiry-endpoints.ts new file mode 100644 index 0000000..16f996a --- /dev/null +++ b/test/support/inquiry-endpoints.ts @@ -0,0 +1,79 @@ +import { InquiryType } from '../../src/common/enums/inquiry-type.enum'; + +export interface InquiryEndpointCase { + name: string; + inquiryType: InquiryType; + path: string; + validPayload: Record; + invalidPayload: Record; +} + +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' }, + }, +]; diff --git a/test/support/mock-provider.ts b/test/support/mock-provider.ts new file mode 100644 index 0000000..2683924 --- /dev/null +++ b/test/support/mock-provider.ts @@ -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', + }), + }, +]; diff --git a/test/unit/helpers-and-mappers.spec.ts b/test/unit/helpers-and-mappers.spec.ts new file mode 100644 index 0000000..d42f9b1 --- /dev/null +++ b/test/unit/helpers-and-mappers.spec.ts @@ -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(` + + 200 + OK: matched + + `) as Record); + + 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'], + }, + ], + }); + }); +});