Compare commits

...

42 Commits

Author SHA1 Message Date
6ddb06594b Merge pull request 'Fixed captcha for prod' (#96) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#96
2026-05-31 16:16:30 +03:30
SepehrYahyaee
e90c6a5c50 Fixed captcha for prod 2026-05-31 16:15:55 +03:30
97f26d400f Merge pull request 'Fixed Swagger ui in prod' (#95) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#95
2026-05-31 15:04:56 +03:30
SepehrYahyaee
02a69f3db2 Fixed Swagger ui in prod 2026-05-31 15:03:46 +03:30
c137d6c6c4 Merge pull request 'Fixed Captcha' (#94) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#94
2026-05-31 14:01:51 +03:30
SepehrYahyaee
2b7192151d Fixed Captcha 2026-05-31 14:01:04 +03:30
389133e1c9 Merge pull request 'Added badane API inquiry' (#93) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#93
2026-05-30 17:17:25 +03:30
SepehrYahyaee
768d6d12fe Removed logs 2026-05-30 17:15:35 +03:30
SepehrYahyaee
84b752c6cc External API for badane 2026-05-30 17:15:21 +03:30
0622ceeaf4 Merge pull request 'Fixed resend blame and claim sms start' (#92) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#92
2026-05-30 16:18:49 +03:30
SepehrYahyaee
3b0db0d250 Fixed resend blame and claim sms start 2026-05-30 16:18:11 +03:30
c502adbe76 Merge pull request 'reduced minimum bytes of medias' (#91) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#91
2026-05-30 15:20:06 +03:30
SepehrYahyaee
7aada14551 reduced minimum bytes of medias 2026-05-30 15:19:04 +03:30
b3bf1b85f8 Merge pull request 'main' (#90) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#90
2026-05-30 15:02:35 +03:30
SepehrYahyaee
d6f1cb9eeb Fixed company code error not found 2026-05-30 15:01:53 +03:30
SepehrYahyaee
da298a3350 Showing a valid message while the OTP is still valid, instead of 400 2026-05-30 11:37:09 +03:30
722cbbf5c9 Merge pull request 'Added .env.example' (#89) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#89
2026-05-30 10:55:22 +03:30
SepehrYahyaee
245160bfc2 Added .env.example 2026-05-30 10:54:52 +03:30
9c760d59f7 Merge pull request 'ENV edit, Joi validation' (#88) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#88
2026-05-30 10:38:12 +03:30
SepehrYahyaee
6ac0bf060e Added Joi for schema validation, tidied up envs 2026-05-30 10:37:23 +03:30
10df869efb Tidied up the project 2026-05-30 08:59:57 +03:30
5c372947dd Merge pull request 'Fixed enrichedEvaluation' (#87) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#87
2026-05-26 16:36:29 +03:30
SepehrYahyaee
9003a7abb6 Fixed enrichedEvaluation 2026-05-26 16:35:13 +03:30
a994331439 Merge pull request 'Changed bcrypt to scrypt built-in' (#86) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#86
2026-05-25 16:44:46 +03:30
SepehrYahyaee
ae12049e1b Changed bcrypt to scrypt built-in 2026-05-25 16:44:43 +03:30
02031efdb5 Merge pull request 'Tidied up the packages and unused modules' (#85) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#85
2026-05-25 14:59:51 +03:30
SepehrYahyaee
48cc4d8a8d Tidied up the packages and unused modules 2026-05-25 14:59:37 +03:30
cbbb45378d Merge pull request 'YARA-885, + fixes' (#84) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#84
2026-05-25 14:15:37 +03:30
SepehrYahyaee
680f3c1798 Fixed resendCarParts label_fa's + OTP 2026-05-25 14:13:17 +03:30
SepehrYahyaee
64fa560f73 YARA-951 case-4 2026-05-25 13:11:37 +03:30
SepehrYahyaee
ff94fa35bf YARA-885 2026-05-25 12:01:00 +03:30
037d9fa934 Merge pull request 'YARA-951' (#83) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#83
2026-05-24 13:34:44 +03:30
SepehrYahyaee
2bdd0d507e YARA-951 2026-05-24 13:34:43 +03:30
f60efa52b1 Merge pull request 'FIX Captcha' (#82) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#82
2026-05-24 10:56:37 +03:30
SepehrYahyaee
866696094f FIX Catcha 2026-05-24 10:56:28 +03:30
ed936ad7b1 Merge pull request 'YARA-913' (#81) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#81
2026-05-24 10:12:31 +03:30
SepehrYahyaee
4d4106a8ab Change minimum size of files 2026-05-24 10:11:52 +03:30
SepehrYahyaee
af875a4773 YARA-913 2026-05-24 10:10:17 +03:30
91221a6848 Merge pull request 'FIX FILE SIZES' (#80) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#80
2026-05-23 16:23:15 +03:30
SepehrYahyaee
a31196774c FIX FILE SIZES 2026-05-23 16:23:11 +03:30
d2474d65bc Merge pull request 'YARA-941' (#79) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#79
2026-05-23 15:51:33 +03:30
SepehrYahyaee
94bde88cb6 YARA-941 2026-05-23 14:05:11 +03:30
86 changed files with 9953 additions and 13861 deletions

72
.env.example Normal file
View File

@@ -0,0 +1,72 @@
# ---------------------------------------------
# 🔧 Application Environment
# ---------------------------------------------
NODE_ENV =
PORT =
# ---------------------------------------------
# 🌐 Application URLs
# ---------------------------------------------
URL =
BASE_URL_DEV =
# ---------------------------------------------
# 📄 Swagger / API Documentation
# ---------------------------------------------
SWAGGER_USER_DEV =
SWAGGER_PASSWORD_DEV =
# ---------------------------------------------
# 🗄️ Database (MongoDB)
# ---------------------------------------------
MONGO_HOST =
MONGO_PORT =
MONGO_USER =
MONGO_PASS =
MONGO_DB_NAME =
MONGO_OPTIONS =
MONGO_TLS =
MONGO_TLS_ALLOW_INVALID_CERTS =
# ---------------------------------------------
# 🔐 Authentication / Security
# ---------------------------------------------
JWT_SECRET =
# ---------------------------------------------
# 🧩 SanHub Microservice
# ---------------------------------------------
SANHUB_BASE_URL =
SANHUB_URL_LOGIN =
SANHUB_USERNAME =
SANHUB_PASSWORD =
# ---------------------------------------------
# 🤖 AI Services
# ---------------------------------------------
AI_URL =
AI_URL_V2 =
AI_USERNAME =
AI_PASSWORD =
# ---------------------------------------------
# 📩 SMS
# ---------------------------------------------
SMS_PROVIDER =
SMS_API_KEY =
AUTH_SMS_TEMPLATE =
EXP_OTP_TIME =
# ---------------------------------------------
# ⚙️ Application Features / Flags
# ---------------------------------------------
AUTO_CLIENT_KEY_ENABLED =
# ---------------------------------------------
# 🔗 Other Internal Services
# ---------------------------------------------
TEJARAT_INQUIRY_EMAIL =
TEJARAT_INQUIRY_PASSWORD =
PARSIAN_API_KEY =
PARSIAN_BASIC_TOKEN =
PARSIAN_SMS_URL =

10
LICENSE
View File

@@ -1,10 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View File

@@ -1,2 +1 @@
# api # api

View File

@@ -1 +0,0 @@
{"1000":{"info":"start","message":"start"},"1001":{"info":"Access Denied Other Actor Lock File","message":""},"1004":{"info":"g","message":""},"1005":{"info":"fSF","message":""},"1006":{"info":"request not found ","message":""}}

15917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "yara724", "name": "yara724",
"version": "0.0.1", "version": "2.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -16,80 +16,61 @@
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"engines": {
"npm": ">=10.0.0",
"node": ">=20.0.0"
},
"dependencies": { "dependencies": {
"@arashioz/errjson-talieh": "^2.2.5", "@nestjs/axios": "^4.0.1",
"@fraybabak/kavenegar_nest": "^1.0.5", "@nestjs/common": "^11.0.17",
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4", "@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^11.0.2",
"@nestjs/mapped-types": "*", "@nestjs/mongoose": "^11.0.4",
"@nestjs/mongoose": "^10.1.0", "@nestjs/platform-express": "^11.1.11",
"@nestjs/passport": "^10.0.3", "@nestjs/serve-static": "^5.0.5",
"@nestjs/platform-express": "^10.4.15", "@nestjs/swagger": "^11.4.4",
"@nestjs/platform-fastify": "^10.4.15", "axios": "^1.16.1",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/websockets": "^10.4.15",
"@types/uuid": "^10.0.0",
"axios": "^1.9.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.15.1",
"crypto": "^1.0.1", "express": "^5.2.1",
"dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2", "joi": "^18.2.1",
"jalali-moment": "^3.3.11",
"kavenegar": "^1.1.4",
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"nestjs-command": "^3.1.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"short-unique-id": "^5.2.0", "svg-captcha": "^1.4.0"
"standard": "^17.1.2",
"standardjs": "^1.0.0-alpha",
"uuid": "^11.0.3",
"yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.14", "@eslint/eslintrc": "^3.2.0",
"@nestjs/schematics": "^10.2.3", "@eslint/js": "^9.18.0",
"@nestjs/testing": "^10.4.15", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.10.8",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^1.4.12", "@types/multer": "^2.1.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/yargs": "^17.0.33", "eslint": "^9.18.0",
"@typescript-eslint/eslint-plugin": "^8.18.1", "eslint-config-prettier": "^10.0.1",
"@typescript-eslint/parser": "^8.18.1", "eslint-plugin-prettier": "^5.2.3",
"eslint": "^9.17.0", "globals": "^15.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"ts-standard": "^12.0.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2" "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -106,9 +87,12 @@
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node", "testEnvironment": "node"
"moduleNameMapper": { },
"^src/(.*)$": "<rootDir>/$1" "pnpm": {
} "onlyBuiltDependencies": [
"@nestjs/core",
"@swc/core"
]
} }
} }

View File

@@ -51,11 +51,14 @@ export function cloneResendUploadedDocuments(
} }
/** How the mobile/web client should collect each resend item (no workflow-step manager). */ /** How the mobile/web client should collect each resend item (no workflow-step manager). */
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text"; export type ResendItemInputKind =
| "document_camera"
| "voice"
| "video"
| "text";
export function getResendItemInputKind(item: string): ResendItemInputKind { export function getResendItemInputKind(item: string): ResendItemInputKind {
if (item === ResendItemType.VOICE) return "voice"; if (item === ResendItemType.VOICE) return "voice";
if (item === ResendItemType.VIDEO) return "video";
if (item === ResendItemType.DESCRIPTION) return "text"; if (item === ResendItemType.DESCRIPTION) return "text";
return "document_camera"; return "document_camera";
} }
@@ -80,13 +83,15 @@ export function isResendPartyItemSatisfied(
): boolean { ): boolean {
const uploaded = row.uploadedDocuments || {}; const uploaded = row.uploadedDocuments || {};
if (item === ResendItemType.DESCRIPTION) { if (item === ResendItemType.DESCRIPTION) {
return !!(row.userTextDescription && String(row.userTextDescription).trim()); return !!(
row.userTextDescription && String(row.userTextDescription).trim()
);
} }
if (item === ResendItemType.VOICE) { if (item === ResendItemType.VOICE) {
return !!row.resendVoiceId; return !!row.resendVoiceId;
} }
if (item === ResendItemType.VIDEO) { // if (item === ResendItemType.VIDEO) {
return !!row.resendVideoId; // return !!row.resendVideoId;
} // }
return !!uploaded[item]; return !!uploaded[item];
} }

View File

@@ -5,11 +5,10 @@ export enum ResendItemType {
CAR_CERTIFICATE = "carCertificate", CAR_CERTIFICATE = "carCertificate",
DRIVING_LICENSE = "drivingLicense", DRIVING_LICENSE = "drivingLicense",
CAR_GREEN_CARD = "carGreenCard", CAR_GREEN_CARD = "carGreenCard",
PLATE = "plate",
CAR_PLATE = "carPlate", CAR_PLATE = "carPlate",
CHASSIS_NUMBER = "chassisNumber",
// Media evidence // Media evidence
VOICE = "voice", VOICE = "voice",
VIDEO = "video",
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */ /** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
DESCRIPTION = "description", DESCRIPTION = "description",

View File

@@ -1,7 +1,7 @@
export enum ClaimRequiredDocumentType { export enum ClaimRequiredDocumentType {
// Car green card // Car green card
CAR_GREEN_CARD = "car_green_card", CAR_GREEN_CARD = "car_green_card",
CAR_CERTIFICATE = "car_certificate",
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */ /** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
NATIONAL_CARD = "national_card", NATIONAL_CARD = "national_card",

View File

@@ -4,15 +4,13 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
Logger,
OnModuleInit, OnModuleInit,
} from "@nestjs/common"; } from "@nestjs/common";
import axios, { AxiosRequestConfig } from "axios"; import { ConfigService } from "@nestjs/config";
import * as FormData from "form-data"; import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
@Injectable() @Injectable()
export class AiService implements OnModuleInit { export class AiService implements OnModuleInit {
private readonly logger = new Logger(AiService.name);
private apiKey: string; private apiKey: string;
private accessToken: string = null; private accessToken: string = null;
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
private readonly loginOptions: AxiosRequestConfig = { private readonly loginOptions: AxiosRequestConfig = {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
url: `${process.env.AI_URL_V2}/auth/login`, url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
data: { data: {
username: process.env.AI_USERNAME, username: this.configService.get<string>("AI_USERNAME"),
password: process.env.AI_PASSWORD, password: this.configService.get<string>("AI_PASSWORD"),
}, },
timeout: 1000, // 30 second timeout timeout: 1000, // TODO: Make this ENV
}; };
constructor(private readonly configService: ConfigService) {}
private get profileOptions(): AxiosRequestConfig { private get profileOptions(): AxiosRequestConfig {
return { return {
method: "GET", method: "GET",
url: `${process.env.AI_URL_V2}/auth/profile`, url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
}, },
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
}; };
} }
constructor() {}
async onModuleInit() { async onModuleInit() {
try { try {
const res = await this.login(); const res = await this.login();
if (res?.accessToken) { // if (res?.accessToken) {
this.logger.verbose("AI Service Authenticated Successfully."); // this.accessToken = res.accessToken;
this.accessToken = res.accessToken; // await this.getApiKey();
await this.getApiKey(); // }
this.logger.log("AI Service initialized and ready.");
} else {
this.logger.warn(
"AI Service Unavailable: Login did not return an access token. Will retry on first request.",
);
}
} catch (error) { } catch (error) {
// Don't prevent app startup if AI service is temporarily unavailable // Don't prevent app startup if AI service is temporarily unavailable
// The service will attempt to re-authenticate when aiRequestImage is called // The service will attempt to re-authenticate when aiRequestImage is called
this.logger.warn(
"AI Service Unavailable: Failed during initial login. Will retry on first request.",
);
this.logger.warn(`Error: ${error.message}`);
// Reset tokens so re-authentication will be attempted // Reset tokens so re-authentication will be attempted
this.accessToken = null; this.accessToken = null;
this.apiKey = null; this.apiKey = null;
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
} }
private async login() { private async login() {
try { // const loginResponse = await axios.request(this.loginOptions);
const loginResponse = await axios.request(this.loginOptions); // return loginResponse.data;
return loginResponse.data;
} catch (err) {
const errorMessage = err.response?.data?.message || err.message || "Unknown error";
const statusCode = err.response?.status || 500;
this.logger.error(`AI login failed: ${errorMessage} (Status: ${statusCode})`);
if (err.response?.data) {
this.logger.error(`AI login error details: ${JSON.stringify(err.response.data, null, 2)}`);
}
throw new HttpException(
`Could not authenticate with AI service: ${errorMessage}`,
statusCode >= 400 && statusCode < 500 ? statusCode : HttpStatus.UNAUTHORIZED,
);
}
} }
private async getApiKey() { private async getApiKey() {
try { // const profileResponse = await axios.request(this.profileOptions);
const profileResponse = await axios.request(this.profileOptions); // this.apiKey = profileResponse.data.apiKey.key;
this.apiKey = profileResponse.data.apiKey.key; // return this.apiKey;
this.logger.log("Successfully retrieved AI gateway API key.");
return this.apiKey;
} catch (err) {
this.logger.error("Failed to retrieve AI API key:", err.message);
throw new HttpException(
"Could not get API key from AI service",
HttpStatus.FAILED_DEPENDENCY,
);
}
} }
public async aiRequestImage(file: { path: string; fileName?: string }): Promise<any> { public async aiRequestImage(file: {
path: string;
fileName?: string;
}): Promise<any> {
// Ensure authentication is set up // Ensure authentication is set up
if (!this.accessToken || !this.apiKey) { if (!this.accessToken || !this.apiKey) {
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
try { try {
const res = await this.login(); const res = await this.login();
if (res?.accessToken) { // if (res?.accessToken) {
this.accessToken = res.accessToken; // this.accessToken = res.accessToken;
await this.getApiKey(); // await this.getApiKey();
} else { // } else {
throw new HttpException( // throw new HttpException(
"AI Service authentication failed", // "AI Service authentication failed",
HttpStatus.UNAUTHORIZED, // HttpStatus.UNAUTHORIZED,
); // );
} // }
} catch (error) { } catch (error) {
this.logger.error("Failed to re-authenticate AI service:", error.message);
throw new HttpException( throw new HttpException(
"AI Service authentication failed", "AI Service authentication failed",
HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED,
@@ -140,100 +107,69 @@ export class AiService implements OnModuleInit {
? file.path ? file.path
: join(process.cwd(), file.path.replace(/^\.\//, "")); : join(process.cwd(), file.path.replace(/^\.\//, ""));
this.logger.log(`Processing AI image request for: ${filePath}`);
// Check if file exists // Check if file exists
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
this.logger.error(`File not found at path: ${filePath}`);
throw new HttpException( throw new HttpException(
`File not found: ${file.path}`, `File not found: ${file.path}`,
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
const form = new FormData(); // const form = new FormData();
const fileStream = createReadStream(filePath); const fileStream = createReadStream(filePath);
// Append file with filename if available // Append file with filename if available
if (file.fileName) { if (file.fileName) {
form.append("images", fileStream, file.fileName); // form.append("images", fileStream, file.fileName);
} else { } else {
// Extract filename from path if not provided // Extract filename from path if not provided
const pathParts = filePath.split("/"); const pathParts = filePath.split("/");
const extractedFileName = pathParts[pathParts.length - 1]; const extractedFileName = pathParts[pathParts.length - 1];
form.append("images", fileStream, extractedFileName); // form.append("images", fileStream, extractedFileName);
} }
try { try {
const requestHeaders = { const requestHeaders = {
...this.imageProcessOptions.headers, ...this.imageProcessOptions.headers,
...form.getHeaders(), // ...form.getHeaders(),
}; };
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`); const fs = require("fs");
this.logger.log(`[STEP 1/4] File: ${filePath}, Filename: ${file.fileName || 'extracted from path'}`);
this.logger.log(`[STEP 1/4] FormData Content-Type: ${form.getHeaders()['content-type']}`);
this.logger.log(`[STEP 1/4] Authorization header present: ${!!requestHeaders.Authorization}`);
this.logger.log(`[STEP 1/4] Gateway API key present: ${!!this.apiKey}`);
this.logger.log(`[STEP 1/4] Request method: POST`);
this.logger.log(`[STEP 1/4] FormData field name: "images"`);
// Get file stats for debugging
const fs = require('fs');
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
this.logger.log(`[STEP 1/4] File size: ${stats.size} bytes`);
this.logger.log(`[STEP 1/4] File exists: ${existsSync(filePath)}`);
const response = await axios.request({ // const response = await axios.request({
...this.imageProcessOptions, // ...this.imageProcessOptions,
headers: requestHeaders, // headers: requestHeaders,
data: form, // // data: form,
maxContentLength: Infinity, // maxContentLength: Infinity,
maxBodyLength: Infinity, // maxBodyLength: Infinity,
}); // });
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`);
// Validate response structure // Validate response structure
if (!response.data) { // if (!response.data) {
this.logger.error(`[ERROR] AI response is empty or missing data`); // throw new HttpException(
throw new HttpException( // "AI Service returned empty response",
"AI Service returned empty response", // HttpStatus.BAD_GATEWAY,
HttpStatus.BAD_GATEWAY, // );
); // }
}
// Check for error in response first (AI service returns 201 with error in body) // // Check for error in response first (AI service returns 201 with error in body)
if (response.data.error) { // if (response.data.error) {
this.logger.error(`[ERROR] AI service returned an error in response body`); // throw new HttpException(
this.logger.error(`[ERROR] Error message: ${response.data.error}`); // `AI Service error: ${response.data.error}`,
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`); // HttpStatus.BAD_GATEWAY,
throw new HttpException( // );
`AI Service error: ${response.data.error}`, // }
HttpStatus.BAD_GATEWAY,
);
}
// Check for processed image (downloadLink) // // Check for processed image (downloadLink)
if (!response.data.downloadLink) { // if (!response.data.downloadLink) {
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`); // throw new HttpException(
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`); // "AI Service did not return processed image (downloadLink missing)",
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`); // HttpStatus.BAD_GATEWAY,
throw new HttpException( // );
"AI Service did not return processed image (downloadLink missing)", // }
HttpStatus.BAD_GATEWAY,
);
}
// Check for reports // return response.data;
if (!response.data.reports) {
this.logger.warn(`[WARNING] AI response missing reports object, but downloadLink exists`);
this.logger.warn(`[WARNING] Response keys: ${JSON.stringify(Object.keys(response.data))}`);
}
this.logger.log(`[STEP 3/4] Validated AI response - downloadLink: ${response.data.downloadLink ? 'present' : 'missing'}, reports: ${response.data.reports ? 'present' : 'missing'}`);
return response.data;
} catch (er) { } catch (er) {
// Determine error source // Determine error source
let errorSource = "UNKNOWN"; let errorSource = "UNKNOWN";
@@ -242,7 +178,10 @@ export class AiService implements OnModuleInit {
if (er.response) { if (er.response) {
errorSource = "AI_SERVICE_RESPONSE"; errorSource = "AI_SERVICE_RESPONSE";
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`; errorMessage =
er.response?.data?.message ||
er.message ||
`HTTP ${er.response.status}`;
errorDetails = er.response?.data errorDetails = er.response?.data
? JSON.stringify(er.response.data, null, 2) ? JSON.stringify(er.response.data, null, 2)
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`; : `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
@@ -255,14 +194,6 @@ export class AiService implements OnModuleInit {
errorMessage = er.message || "Error setting up request"; errorMessage = er.message || "Error setting up request";
} }
this.logger.error(`[ERROR] AI request failed - Source: ${errorSource}`);
this.logger.error(`[ERROR] File path: ${filePath}`);
this.logger.error(`[ERROR] Error message: ${errorMessage}`);
this.logger.error(`[ERROR] Error details: ${errorDetails}`);
if (er.stack) {
this.logger.error(`[ERROR] Stack trace: ${er.stack}`);
}
// Re-throw with detailed error information // Re-throw with detailed error information
throw new HttpException( throw new HttpException(
`[${errorSource}] ${errorMessage}`, `[${errorSource}] ${errorMessage}`,

View File

@@ -1,12 +1,10 @@
import { join } from "node:path"; import { join } from "node:path";
import { Module, ValidationPipe } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { Module, ValidationPipe } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor"; import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule";
import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticModule } from "@nestjs/serve-static";
import * as dotenv from "dotenv";
import { CommandModule } from "nestjs-command";
import { AiModule } from "./ai/ai.module"; import { AiModule } from "./ai/ai.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module"; import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
@@ -25,35 +23,79 @@ import { UsersModule } from "./users/users.module";
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin"; import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import * as Joi from "joi";
dotenv.config();
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@Module({ @Module({
imports: [ imports: [
CommandModule, ConfigModule.forRoot({
ScheduleModule.forRoot(), isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid("development", "production")
.default("development"),
PORT: Joi.number().port().default(9001),
URL: Joi.string().uri().required(),
BASE_URL_DEV: Joi.string()
.uri()
.when("NODE_ENV", {
is: Joi.string().valid("development"),
then: Joi.required(),
otherwise: Joi.optional(),
}),
SWAGGER_USER_DEV: Joi.string().when("NODE_ENV", {
is: Joi.string().valid("development"),
then: Joi.required(),
otherwise: Joi.optional(),
}),
SWAGGER_PASSWORD_DEV: Joi.string().when("NODE_ENV", {
is: Joi.string().valid("development"),
then: Joi.required(),
otherwise: Joi.optional(),
}),
MONGO_HOST: Joi.string().required(),
MONGO_PORT: Joi.number().port().required(),
MONGO_USER: Joi.string().required(),
MONGO_PASS: Joi.string().required(),
MONGO_DB_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
SANHUB_BASE_URL: Joi.string().uri(),
SANHUB_URL_LOGIN: Joi.string().uri(),
SANHUB_USERNAME: Joi.string(),
SANHUB_PASSWORD: Joi.string(),
AI_URL: Joi.string().uri(),
AI_URL_V2: Joi.string().uri(),
AI_USERNAME: Joi.string(),
AI_PASSWORD: Joi.string(),
SMS_PROVIDER: Joi.string()
.valid("kavenegar", "parsian")
.default("kavenegar"),
SMS_API_KEY: Joi.string(),
AUTH_SMS_TEMPLATE: Joi.string(),
EXP_OTP_TIME: Joi.number(),
}),
}),
CronModule, CronModule,
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, "..", "files"), rootPath: join(__dirname, "..", "files"),
serveRoot: "/files", serveRoot: "/files",
}), }),
MongooseModule.forRoot( MongooseModule.forRootAsync({
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`, inject: [ConfigService],
{ useFactory: (configService: ConfigService) => {
dbName: "yara724", return {
autoIndex: true, uri: `mongodb://${configService.get<string>("MONGO_USER")}:${configService.get<string>("MONGO_PASS")}@${configService.get<string>("MONGO_HOST")}:${configService.get<string>("MONGO_PORT")}/${configService.get<string>("MONGO_DB_NAME")}?authSource=admin&${configService.get<string>("MONGO_OPTIONS")}`,
user: process.env.MONGO_USER, tls: configService.get<string>("MONGO_TLS") === "true",
pass: process.env.MONGO_PASS, tlsAllowInvalidCertificates:
authMechanism: "SCRAM-SHA-256", configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") ===
tls: true, "true",
tlsAllowInvalidCertificates: true, autoIndex: configService.get<string>("NODE_ENV") !== "production",
connectionFactory: (connection) => { connectionFactory: (connection) => {
applyIranFaTimestampPlugin(connection); applyIranFaTimestampPlugin(connection);
return connection; return connection;
}, },
};
}, },
), }),
UsersModule, UsersModule,
AuthModule, AuthModule,
ClientModule, ClientModule,
@@ -78,12 +120,12 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
useClass: UnicodeDigitsNormalizeInterceptor, useClass: UnicodeDigitsNormalizeInterceptor,
}, },
// { // {
// provide: APP_PIPE, // provide: APP_PIPE,
// useValue: new ValidationPipe({ // useValue: new ValidationPipe({
// transform: true, // transform: true,
// whitelist: true, // whitelist: true,
// forbidNonWhitelisted: false, // forbidNonWhitelisted: false,
// }), // }),
// }, // },
], ],
}) })

View File

@@ -5,9 +5,12 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Query,
Req, Req,
Res,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Response } from "express";
import { import {
ApiBody, ApiBody,
ApiAcceptedResponse, ApiAcceptedResponse,
@@ -17,6 +20,9 @@ import {
ApiBearerAuth, ApiBearerAuth,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
import { GetCaptchaImageQueryDto } from "src/auth/dto/get-captcha-image-query.dto";
import { import {
ForgetPasswordSendCodeDto, ForgetPasswordSendCodeDto,
ForgetPasswordVerifyCodeDto, ForgetPasswordVerifyCodeDto,
@@ -37,7 +43,52 @@ import { CurrentUser } from "src/decorators/user.decorator";
@Controller("actor") @Controller("actor")
@ApiTags("actor") @ApiTags("actor")
export class ActorAuthController { export class ActorAuthController {
constructor(private readonly actorAuthService: ActorAuthService) {} constructor(
private readonly actorAuthService: ActorAuthService,
private readonly captchaChallengeService: CaptchaChallengeService,
) {}
@Get("captcha")
@ApiOperation({
summary: "Get a login captcha",
description:
"Issues a new captcha challenge. Returns `captchaId`, `image`, and `expiresAt`. " +
"Send `captchaId` and the typed characters as `captcha` on POST /actor/login.\n\n" +
"Optional `format=raw` returns image/svg+xml for browser preview (same captchaId is in JSON when omitted).",
})
@ApiResponse({ status: 200, type: CaptchaResponseDto })
async getCaptcha(
@Query() query: GetCaptchaImageQueryDto,
@Res({ passthrough: true }) res: Response,
) {
const result = await this.captchaChallengeService.issue();
if (query.format === "raw") {
const svg = await this.captchaChallengeService.getImageById(
result.captchaId,
);
res.setHeader("X-Captcha-Id", result.captchaId);
res.type("image/svg+xml");
res.send(svg);
return;
}
return result;
}
@Get("captcha/:captchaId/image")
@ApiOperation({
summary: "View captcha image by id",
description: "Returns raw SVG for a previously issued captcha challenge.",
})
async getCaptchaImage(
@Param("captchaId") captchaId: string,
@Res({ passthrough: true }) res: Response,
) {
const svg = await this.captchaChallengeService.getImageById(captchaId);
res.type("image/svg+xml");
res.send(svg);
}
/** /**
* @deprecated Use the unified actor onboarding flow instead. This endpoint * @deprecated Use the unified actor onboarding flow instead. This endpoint
@@ -113,6 +164,8 @@ export class ActorAuthController {
role: "company", role: "company",
username: "saman_insurer@gmail.com", username: "saman_insurer@gmail.com",
password: "123321", password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
}, },
}, },
expert: { expert: {
@@ -122,6 +175,8 @@ export class ActorAuthController {
role: "expert", role: "expert",
username: "blame@gmail.com", username: "blame@gmail.com",
password: "123321", password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
}, },
}, },
damage_expert: { damage_expert: {
@@ -131,6 +186,8 @@ export class ActorAuthController {
role: "damage_expert", role: "damage_expert",
username: "claim@gmail.com", username: "claim@gmail.com",
password: "123321", password: "123321",
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
captcha: "a7bx2",
}, },
}, },
}, },

View File

@@ -30,6 +30,7 @@ export class UserAuthController {
linkToken: body.linkToken, linkToken: body.linkToken,
linkContext: body.linkContext, linkContext: body.linkContext,
}); });
if (res) { if (res) {
throw new HttpException(res, HttpStatus.ACCEPTED); throw new HttpException(res, HttpStatus.ACCEPTED);
} }

View File

@@ -18,6 +18,7 @@ import {
RegisterDtoRs, RegisterDtoRs,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { StateListDtoRs } from "src/auth/dto/actor/states.dto"; import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service"; import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum"; import { UserType } from "src/Types&Enums/userType.enum";
@@ -27,7 +28,6 @@ import { ExpertDbService } from "src/users/entities/db-service/expert.db.service
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service"; import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
@@ -49,9 +49,9 @@ export class ActorAuthService {
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService, private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpGeneratorService, private readonly otpService: OtpGeneratorService,
private readonly captchaChallengeService: CaptchaChallengeService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -78,8 +78,7 @@ export class ActorAuthService {
res = await this.fieldExpertDbService.findOne({ res = await this.fieldExpertDbService.findOne({
_id: new Types.ObjectId(userId), _id: new Types.ObjectId(userId),
}); });
else else res = await this.fieldExpertDbService.findOne({ email: username });
res = await this.fieldExpertDbService.findOne({ email: username });
break; break;
case RoleEnum.REGISTRAR: case RoleEnum.REGISTRAR:
if (username == null && userId) if (username == null && userId)
@@ -139,7 +138,7 @@ export class ActorAuthService {
}; };
const access_token = this.jwtService.sign(payload, { const access_token = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
expiresIn: "1h", expiresIn: "1h",
}); });
@@ -159,9 +158,7 @@ export class ActorAuthService {
throw new UnauthorizedException("user not assigned to this role"); throw new UnauthorizedException("user not assigned to this role");
} }
if (!(await this.hashService.compare(pass, user.password))) { if (!(await this.hashService.compare(pass, user.password))) {
throw new UnauthorizedException( throw new UnauthorizedException("password is incorrect or access Denied");
"password is incorrect or access Denied",
);
} }
return user; return user;
} }
@@ -176,6 +173,11 @@ export class ActorAuthService {
if (typeof password !== "string" || !password) { if (typeof password !== "string" || !password) {
throw new BadRequestException("password is required"); throw new BadRequestException("password is required");
} }
const captchaId =
typeof body?.captchaId === "string" ? body.captchaId : undefined;
const captcha =
typeof body?.captcha === "string" ? body.captcha : undefined;
await this.captchaChallengeService.verify(captchaId, captcha);
const actor = await this.validateActor(username, password, role); const actor = await this.validateActor(username, password, role);
return this.issueActorTokens(actor); return this.issueActorTokens(actor);
} }
@@ -220,7 +222,7 @@ export class ActorAuthService {
firstName: body.firstName, firstName: body.firstName,
lastName: body.lastName, lastName: body.lastName,
phone: body.phone, phone: body.phone,
mobile:body.mobile, mobile: body.mobile,
city: body.city, city: body.city,
state: body.state, state: body.state,
address: body.address, address: body.address,
@@ -440,13 +442,7 @@ export class ActorAuthService {
"state", "state",
"address", "address",
], ],
field_expert: [ field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
"firstName",
"lastName",
"email",
"phone",
"mobile",
],
registrar: ["email"], registrar: ["email"],
}; };
@@ -472,7 +468,11 @@ export class ActorAuthService {
} }
// fetch user detail (document or plain object) // fetch user detail (document or plain object)
const document = await this.dynamicDbController(role, currentUser.role === "company" ? currentUser.username : null, userId); const document = await this.dynamicDbController(
role,
currentUser.role === "company" ? currentUser.username : null,
userId,
);
if (!document) throw new NotFoundException("Profile not found"); if (!document) throw new NotFoundException("Profile not found");

View File

@@ -0,0 +1,38 @@
import {
BadRequestException,
UnauthorizedException,
} from "@nestjs/common";
export enum CaptchaAuthErrorCode {
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
CAPTCHA_NOT_FOUND = "CAPTCHA_NOT_FOUND",
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
CAPTCHA_INVALID = "CAPTCHA_INVALID",
}
const messages: Record<CaptchaAuthErrorCode, string> = {
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
"Captcha is required. Request a new captcha image first.",
[CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND]:
"Captcha id was not found. Request a new captcha image.",
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
"Captcha has expired. Request a new captcha image.",
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
};
export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
return {
code,
message: messages[code],
};
}
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
if (
code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED ||
code === CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND
) {
throw new BadRequestException(captchaAuthErrorBody(code));
}
throw new UnauthorizedException(captchaAuthErrorBody(code));
}

View File

@@ -34,9 +34,9 @@ export function userAuthErrorBody(code: UserAuthErrorCode) {
} }
export function throwUserAuthError(code: UserAuthErrorCode): never { export function throwUserAuthError(code: UserAuthErrorCode): never {
if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) { // if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
throw new BadRequestException(userAuthErrorBody(code)); // throw new BadRequestException(userAuthErrorBody(code));
} // }
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) { if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
throw new ForbiddenException(userAuthErrorBody(code)); throw new ForbiddenException(userAuthErrorBody(code));

View File

@@ -5,7 +5,9 @@ import {
UserAuthErrorCode, UserAuthErrorCode,
throwUserAuthError, throwUserAuthError,
} from "src/auth/auth-services/user-auth-error"; } from "src/auth/auth-services/user-auth-error";
import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema";
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema"; import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { normalizeIranMobile } from "src/helpers/iran-mobile";
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema"; import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema"; import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
@@ -20,6 +22,8 @@ export class UserLinkAccessService {
private readonly blameRequestModel: Model<BlameRequest>, private readonly blameRequestModel: Model<BlameRequest>,
@InjectModel(ClaimRequestManagementModel.name) @InjectModel(ClaimRequestManagementModel.name)
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>, private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
@InjectModel(ClaimCase.name)
private readonly claimCaseModel: Model<ClaimCase>,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
) {} ) {}
@@ -39,10 +43,12 @@ export class UserLinkAccessService {
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND); throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
} }
const normalizedMobile = this.normalizePhone(params.mobile); const normalizedMobile = normalizeIranMobile(params.mobile);
if ( if (
!normalizedMobile || !normalizedMobile ||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile) !allowedMobiles.some(
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
)
) { ) {
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH); throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
} }
@@ -58,15 +64,18 @@ export class UserLinkAccessService {
const context = this.normalizeContext(linkContext); const context = this.normalizeContext(linkContext);
const allowedMobiles = new Set<string>(); const allowedMobiles = new Set<string>();
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([ const [legacyRequest, blameRequest, legacyClaim, claimCase] =
this.requestManagementModel.findById(id).lean().exec(), await Promise.all([
this.blameRequestModel.findById(id).lean().exec(), this.requestManagementModel.findById(id).lean().exec(),
this.claimRequestManagementModel.findById(id).lean().exec(), this.blameRequestModel.findById(id).lean().exec(),
]); this.claimRequestManagementModel.findById(id).lean().exec(),
this.claimCaseModel.findById(id).lean().exec(),
]);
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context); this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
this.addBlameRequestPhones(allowedMobiles, blameRequest, context); this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
await this.addClaimOwnerPhone(allowedMobiles, claimRequest); await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
return Array.from(allowedMobiles); return Array.from(allowedMobiles);
} }
@@ -119,7 +128,7 @@ export class UserLinkAccessService {
} }
} }
private async addClaimOwnerPhone( private async addLegacyClaimOwnerPhone(
allowedMobiles: Set<string>, allowedMobiles: Set<string>,
claimRequest: any, claimRequest: any,
) { ) {
@@ -146,29 +155,51 @@ export class UserLinkAccessService {
_id: new Types.ObjectId(ownerUserIdText), _id: new Types.ObjectId(ownerUserIdText),
}); });
this.addPhone(allowedMobiles, user?.mobile); this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
}
}
/** V2 `claimCases` — token in `/caseClaim?token=...` SMS links. */
private async addClaimCaseOwnerPhone(
allowedMobiles: Set<string>,
claimCase: any,
) {
if (!claimCase?.owner?.userId) return;
const ownerUserIdText = String(claimCase.owner.userId);
if (claimCase.blameRequestId) {
const blameRequest = await this.blameRequestModel
.findById(claimCase.blameRequestId)
.lean()
.exec();
const ownerParty = (blameRequest?.parties || []).find(
(party: any) =>
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
);
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
}
if (Types.ObjectId.isValid(ownerUserIdText)) {
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
} }
} }
private addPhone(allowedMobiles: Set<string>, phone?: string) { private addPhone(allowedMobiles: Set<string>, phone?: string) {
const normalized = this.normalizePhone(phone); const normalized = normalizeIranMobile(phone);
if (normalized) allowedMobiles.add(normalized); if (normalized) allowedMobiles.add(normalized);
} }
private normalizePhone(phone?: string): string | undefined {
if (!phone) return undefined;
const digits = String(phone).replace(/\D/g, "");
if (!digits) return undefined;
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
if (digits.startsWith("98") && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
return digits;
}
private normalizeContext(linkContext?: string): string | undefined { private normalizeContext(linkContext?: string): string | undefined {
return linkContext?.trim().toUpperCase(); const ctx = linkContext?.trim().toUpperCase();
if (!ctx) return undefined;
if (ctx === "USER" || ctx === "USER1") return "FIRST";
if (ctx === "USER2") return "SECOND";
if (ctx === "CASECLAIM" || ctx === "CLAIM") return undefined;
return ctx;
} }
private matchesRoleContext(context: string, role?: string): boolean { private matchesRoleContext(context: string, role?: string): boolean {

View File

@@ -7,6 +7,15 @@ import {
} from "src/auth/auth-services/user-auth-error"; } from "src/auth/auth-services/user-auth-error";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service"; import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import {
buildUserLookupByPhone,
normalizeIranMobile,
} from "src/helpers/iran-mobile";
import {
computeOtpExpireMs,
isOtpExpiryActive,
readOtpExpireMinutesFromEnv,
} from "src/helpers/user-otp-expiry";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -17,7 +26,6 @@ export interface LinkBinding {
linkContext?: string; linkContext?: string;
} }
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
export class UserAuthService { export class UserAuthService {
private readonly logger = new Logger(UserAuthService.name); private readonly logger = new Logger(UserAuthService.name);
@@ -36,18 +44,21 @@ export class UserAuthService {
pass: string, pass: string,
binding: LinkBinding = {}, binding: LinkBinding = {},
): Promise<any> { ): Promise<any> {
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
await this.userLinkAccessService.assertMobileAllowed({ await this.userLinkAccessService.assertMobileAllowed({
mobile: username, mobile: canonicalMobile,
linkToken: binding.linkToken, linkToken: binding.linkToken,
linkContext: binding.linkContext, linkContext: binding.linkContext,
}); });
const user = await this.userDbService.findOne({ username }); const user = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND); if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
const now = new Date().getTime();
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED); if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
if (user.otpExpire < now) { if (!isOtpExpiryActive(user.otpExpire)) {
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED); throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
} }
if (await this.hashService.compare(pass, user.otp)) { if (await this.hashService.compare(pass, user.otp)) {
@@ -57,23 +68,25 @@ export class UserAuthService {
} }
async login(user: any) { async login(user: any) {
const userId = String(user._id ?? user.id ?? "");
const payload = { const payload = {
username: user.username, username: user.username,
sub: user.id, sub: userId,
role: "user", role: "user",
}; };
const accToken = this.jwtService.sign(payload, { const accToken = this.jwtService.sign(payload, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
await this.userDbService.findOneAndUpdate( await this.userDbService.findOneAndUpdate(
{ username: user.username }, { username: user.username },
{ {
tokens: { token: accToken }, tokens: { token: accToken },
otp: null, otp: null,
otpExpire: 0,
}, },
); );
return { return {
userId: user._id, userId,
access_token: accToken, access_token: accToken,
}; };
} }
@@ -82,29 +95,31 @@ export class UserAuthService {
mobile: string, mobile: string,
binding: LinkBinding = {}, binding: LinkBinding = {},
): Promise<LoginDtoRs> { ): Promise<LoginDtoRs> {
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
if (!canonicalMobile) {
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
}
await this.userLinkAccessService.assertMobileAllowed({ await this.userLinkAccessService.assertMobileAllowed({
mobile, mobile: canonicalMobile,
linkToken: binding.linkToken, linkToken: binding.linkToken,
linkContext: binding.linkContext, linkContext: binding.linkContext,
}); });
const userExist = await this.userDbService.findOne({ const userExist = await this.userDbService.findOne(
mobile, buildUserLookupByPhone(canonicalMobile),
}); );
const otp = this.otpCreator.create(); const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2"); const expireMinutes = readOtpExpireMinutesFromEnv();
const expireMinutes = const nowMs = Date.now();
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0 const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) { if (!userExist) {
await this.smsSender(otp, mobile); await this.smsSender(otp, canonicalMobile);
/// create otp request
const newUser = await this.userDbService.createUser({ const newUser = await this.userDbService.createUser({
mobile, mobile: canonicalMobile,
username: mobile, username: canonicalMobile,
otp: hashOtp, otp: hashOtp,
tokens: { tokens: {
token: "", token: "",
@@ -122,22 +137,23 @@ export class UserAuthService {
}); });
return new LoginDtoRs(newUser); return new LoginDtoRs(newUser);
} }
if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) { if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON); // throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
} return new LoginDtoRs(userExist, "OTP Still valid");
await this.smsSender(otp, mobile);
const updateTokens = await this.userDbService.findOneAndUpdate(
{
username: userExist.username,
},
{
otp: hashOtp,
otpExpire,
},
);
if (updateTokens) return new LoginDtoRs(userExist);
} }
await this.smsSender(otp, canonicalMobile);
await this.userDbService.findOneAndUpdate(
buildUserLookupByPhone(canonicalMobile),
{
otp: hashOtp,
otpExpire,
mobile: canonicalMobile,
username: userExist.username || canonicalMobile,
},
);
return new LoginDtoRs(userExist);
} }
private async smsSender(otp: string, mobile: string) { private async smsSender(otp: string, mobile: string) {
@@ -147,10 +163,7 @@ export class UserAuthService {
process.env.AUTH_SMS_TEMPLATE, process.env.AUTH_SMS_TEMPLATE,
); );
if (!ok) { if (!ok) {
throw new HttpException( throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
"auth sms send failed",
HttpStatus.BAD_GATEWAY,
);
} }
this.logger.log( this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`, `Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,

View File

@@ -1,14 +1,17 @@
import { Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { JwtModule, JwtService } from "@nestjs/jwt"; import { JwtModule, JwtService } from "@nestjs/jwt";
import { MongooseModule } from "@nestjs/mongoose"; import { MongooseModule } from "@nestjs/mongoose";
import { PassportModule } from "@nestjs/passport"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { LocalStrategy } from "src/auth/stratregys/local.strategy"; import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller"; import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller"; import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service"; import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import {
ClaimCase,
ClaimCaseSchema,
} from "src/claim-request-management/entites/schema/claim-cases.schema";
import { import {
ClaimRequestManagementModel, ClaimRequestManagementModel,
ClaimRequestManagementSchema, ClaimRequestManagementSchema,
@@ -24,16 +27,17 @@ import {
} from "src/request-management/entities/schema/request-management.schema"; } from "src/request-management/entities/schema/request-management.schema";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { CaptchaModule } from "src/captcha/captcha.module";
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
@Global()
@Module({ @Module({
imports: [ imports: [
// MailModule, // Mailer disabled not used
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,
PassportModule, CaptchaModule,
SmsOrchestrationModule, SmsOrchestrationModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema }, { name: RequestManagementModel.name, schema: RequestManagementSchema },
@@ -42,22 +46,29 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
name: ClaimRequestManagementModel.name, name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema, schema: ClaimRequestManagementSchema,
}, },
{ name: ClaimCase.name, schema: ClaimCaseSchema },
]), ]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
global: true, global: true,
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}), }),
], ],
providers: [ providers: [
UserAuthService, UserAuthService,
UserLinkAccessService, UserLinkAccessService,
ActorAuthService, ActorAuthService,
LocalStrategy,
LocalActorStrategy,
JwtService, JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
],
exports: [
UserAuthService,
ActorAuthService,
JwtService,
LocalActorAuthGuard,
LocalUserAuthGuard,
], ],
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
controllers: [UserAuthController, ActorAuthController], controllers: [UserAuthController, ActorAuthController],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
export class LoginActorDto { export class LoginActorDto {
@@ -10,6 +11,24 @@ export class LoginActorDto {
@ApiProperty({}) @ApiProperty({})
password: string; password: string;
@ApiProperty({
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
description: "Captcha id from GET /actor/captcha.",
})
@IsString()
@IsNotEmpty()
@MaxLength(64)
captchaId: string;
@ApiProperty({
example: "a7bx2",
description: "Characters shown in the captcha image.",
})
@IsString()
@IsNotEmpty()
@MaxLength(16)
captcha: string;
} }
export class LoginActorDtoRs extends LoginActorDto { export class LoginActorDtoRs extends LoginActorDto {

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from "@nestjs/swagger";
export class CaptchaResponseDto {
@ApiProperty({
description: "Captcha challenge id — send back with POST /actor/login.",
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
})
captchaId: string;
@ApiProperty({
description: "Sample text",
example: "sb20xe"
})
text: string
@ApiProperty({
description:
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
})
image: string;
@ApiProperty({
description: "Unix timestamp (ms) when this captcha expires.",
example: 1710000000000,
})
expiresAt: number;
}

View File

@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
export class GetCaptchaImageQueryDto {
@ApiPropertyOptional({
enum: ["json", "raw"],
default: "json",
description:
"On GET /actor/captcha, use `raw` to return image/svg+xml (for browser preview). " +
"The JSON response includes `captchaId` either way.",
})
@IsOptional()
@IsIn(["json", "raw"])
format?: "json" | "raw";
}

View File

@@ -35,7 +35,9 @@ export class UserLoginDto {
} }
export class LoginDtoRs extends UserModel { export class LoginDtoRs extends UserModel {
@ApiProperty({ type: "string" })
message: string; message: string;
@ApiProperty({ @ApiProperty({
example: "09226187419", example: "09226187419",
type: "string", type: "string",
@@ -61,8 +63,10 @@ export class LoginDtoRs extends UserModel {
username: string; username: string;
mobile: string; mobile: string;
nationalCode: string; nationalCode: string;
constructor(loginData) {
constructor(loginData, message: string = "") {
super(); super();
this.mobile = loginData.mobile; this.mobile = loginData.mobile;
this.message = message;
} }
} }

View File

@@ -1,26 +1,23 @@
import { import {
CanActivate,
ExecutionContext, ExecutionContext,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { AuthGuard } from "@nestjs/passport";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service"; import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
@Injectable() @Injectable()
export class LocalActorAuthGuard extends AuthGuard("actor") { export class LocalActorAuthGuard implements CanActivate {
constructor( constructor(
private readonly actorAuthService: ActorAuthService, private readonly actorAuthService: ActorAuthService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
) { ) {}
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request); const token = this.extractTokenFromHeader(request);
const path = request.url;
if (!token) { if (!token) {
if (this.isActorLoginRequest(request)) { if (this.isActorLoginRequest(request)) {
@@ -30,14 +27,13 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
request.user = loginData; request.user = loginData;
request.identity = loginData; request.identity = loginData;
return true; return true;
} else {
throw new UnauthorizedException("Token not found");
} }
throw new UnauthorizedException("Token not found");
} }
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
if ( if (
@@ -72,9 +68,10 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
return path === "/actor/login" || path.endsWith("/actor/login"); return path === "/actor/login" || path.endsWith("/actor/login");
} }
private extractTokenFromHeader(request: Request): string | undefined { private extractTokenFromHeader(request: {
//@ts-ignore headers?: { authorization?: string };
const [type, token] = request.headers.authorization?.split(" ") ?? []; }): string | undefined {
const [type, token] = request.headers?.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined; return type === "Bearer" ? token : undefined;
} }
} }

View File

@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
// Allow users to pass through (they will be checked by service methods) // Allow users to pass through (they will be checked by service methods)
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
throw new UnauthorizedException("Invalid role"); throw new UnauthorizedException("Invalid role");
} catch (error) { } catch (error) {
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) { if (
error instanceof ForbiddenException ||
error instanceof UnauthorizedException
) {
throw error; throw error;
} }
throw new UnauthorizedException(); throw new UnauthorizedException();
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
return type === "Bearer" ? token : undefined; return type === "Bearer" ? token : undefined;
} }
} }

View File

@@ -22,10 +22,13 @@ export class GlobalGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) { if (
payload.role !== RoleEnum.USER &&
payload.role !== RoleEnum.FIELD_EXPERT
) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }

View File

@@ -24,7 +24,7 @@ export class SettingsJwtGuard implements CanActivate {
try { try {
const payload = await this.jwtService.verifyAsync(token, { const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`, secret: `${process.env.JWT_SECRET}`,
}); });
(request as any).user = payload; (request as any).user = payload;
(request as any).identity = payload; (request as any).identity = payload;

View File

@@ -1,5 +1,8 @@
import { ExecutionContext, Injectable } from "@nestjs/common"; import {
import { AuthGuard } from "@nestjs/passport"; CanActivate,
ExecutionContext,
Injectable,
} from "@nestjs/common";
import { import {
UserAuthErrorCode, UserAuthErrorCode,
throwUserAuthError, throwUserAuthError,
@@ -7,13 +10,12 @@ import {
import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable() @Injectable()
export class LocalUserAuthGuard extends AuthGuard("local") { export class LocalUserAuthGuard implements CanActivate {
constructor(private readonly userAuthService: UserAuthService) { constructor(private readonly userAuthService: UserAuthService) {}
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const { username, password, linkToken, linkContext } = request.body; const { username, password, linkToken, linkContext } = request.body ?? {};
const isValidUser = await this.userAuthService.validateUser( const isValidUser = await this.userAuthService.validateUser(
username, username,
password, password,
@@ -22,7 +24,7 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
if (!isValidUser) { if (!isValidUser) {
throwUserAuthError(UserAuthErrorCode.OTP_INVALID); throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
} }
request["user"] = isValidUser; request.user = isValidUser;
return true; return true;
} }
} }

View File

@@ -1,22 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
@Injectable()
export class LocalActorStrategy extends PassportStrategy(Strategy, "actor") {
constructor(private readonly actorAuthService: ActorAuthService) {
super();
}
// async validate(username, password): Promise<any> {
// const user = await this.actorAuthService.validateActor(
// username,
// password,
// );
// if (!user) {
// throw new UnauthorizedException("user not found");
// }
// return user;
// }
}

View File

@@ -1,23 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userAuthService: UserAuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.userAuthService.validateUser(username, password);
if (!user) {
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
}
return user;
}
}

View File

@@ -0,0 +1,100 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { randomUUID } from "node:crypto";
import {
CaptchaAuthErrorCode,
throwCaptchaAuthError,
} from "src/auth/auth-services/captcha-auth.error";
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
import { CaptchaService } from "src/captcha/captcha.service";
import { CaptchaChallengeDbService } from "src/captcha/entities/db-service/captcha-challenge.db.service";
import { HashService } from "src/utils/hash/hash.service";
@Injectable()
export class CaptchaChallengeService {
private readonly isDev: boolean;
constructor(
private readonly captchaService: CaptchaService,
private readonly hashService: HashService,
private readonly captchaChallengeDbService: CaptchaChallengeDbService,
private readonly configService: ConfigService,
) {
this.isDev = this.configService.get<string>("NODE_ENV") === "development";
}
async issue(): Promise<CaptchaResponseDto> {
const generated = this.captchaService.generate();
const captchaId = randomUUID();
// Always hash and persist the answer — the env check was the root bug
const answerHash = await this.hashService.hash(
this.captchaService.normalizeAnswer(generated.text),
);
await this.captchaChallengeDbService.create({
captchaId,
answerHash,
image: generated.image,
expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt),
usedAt: null,
});
return {
captchaId,
image: generated.image,
expiresAt: generated.expiresAt,
...(this.isDev && { text: generated.text }),
};
}
async getImageById(captchaId: string): Promise<string> {
const challenge =
await this.captchaChallengeDbService.findByCaptchaId(captchaId);
if (!challenge) {
throw new NotFoundException("Captcha not found");
}
return this.decodeImage(challenge.image);
}
async verify(
captchaId: string | undefined,
answer: string | undefined,
): Promise<void> {
if (!captchaId?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}
if (!answer?.trim()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
}
const challenge = await this.captchaChallengeDbService.findByCaptchaId(
captchaId.trim(),
);
if (!challenge) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND);
}
if (challenge.usedAt) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
if (challenge.expiresAt < Date.now()) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
}
const ok = await this.hashService.compare(
this.captchaService.normalizeAnswer(answer),
challenge.answerHash,
);
if (!ok) {
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
}
await this.captchaChallengeDbService.markUsed(challenge.captchaId);
}
private decodeImage(imageDataUri: string): string {
const base64 = imageDataUri.replace(/^data:image\/svg\+xml;base64,/, "");
return Buffer.from(base64, "base64").toString("utf8");
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { HashModule } from "src/utils/hash/hash.module";
import { CaptchaChallengeService } from "./captcha-challenge.service";
import { CaptchaService } from "./captcha.service";
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
import {
CaptchaChallenge,
CaptchaChallengeSchema,
} from "./entities/schema/captcha-challenge.schema";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [
ConfigModule,
HashModule,
MongooseModule.forFeature([
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
]),
],
providers: [
CaptchaService,
CaptchaChallengeDbService,
CaptchaChallengeService,
],
exports: [CaptchaChallengeService],
})
export class CaptchaModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as svgCaptcha from "svg-captcha";
export interface GeneratedCaptcha {
text: string;
image: string;
expiresAt: number;
}
@Injectable()
export class CaptchaService {
generate(): GeneratedCaptcha {
const captcha = svgCaptcha.create({
size: 5,
ignoreChars: "0oO1ilI",
noise: 3,
color: true,
background: "#f8fafc",
width: 160,
height: 56,
fontSize: 52,
});
return {
text: captcha.text,
image: `data:image/svg+xml;base64,${Buffer.from(captcha.data, "utf8").toString("base64")}`,
expiresAt: this.buildExpireAt(),
};
}
normalizeAnswer(input: string): string {
return input.trim().toLowerCase();
}
buildExpireAt(): number {
const raw = Number(
process.env.EXP_CAPTCHA_TIME ?? process.env.EXP_OTP_TIME ?? "2",
);
const minutes = Number.isFinite(raw) && raw > 0 ? raw : 2;
return Date.now() + minutes * 60 * 1000;
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import {
CaptchaChallenge,
CaptchaChallengeDocument,
} from "../schema/captcha-challenge.schema";
@Injectable()
export class CaptchaChallengeDbService {
constructor(
@InjectModel(CaptchaChallenge.name)
private readonly captchaChallengeModel: Model<CaptchaChallengeDocument>,
) {}
create(data: CaptchaChallenge): Promise<CaptchaChallengeDocument> {
return this.captchaChallengeModel.create(data);
}
findByCaptchaId(
captchaId: string,
): Promise<CaptchaChallengeDocument | null> {
return this.captchaChallengeModel.findOne({ captchaId });
}
markUsed(captchaId: string): Promise<CaptchaChallengeDocument | null> {
return this.captchaChallengeModel.findOneAndUpdate(
{ captchaId, usedAt: null },
{ $set: { usedAt: new Date() } },
{ new: true },
);
}
}

View File

@@ -0,0 +1,32 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
@Schema({
collection: "captcha-challenges",
timestamps: true,
versionKey: false,
})
export class CaptchaChallenge {
@Prop({ required: true, unique: true, index: true })
captchaId: string;
@Prop()
answerHash?: string;
@Prop({ required: true })
image: string;
@Prop({ required: true, index: true })
expiresAt: number;
/** Mongo TTL — document removed shortly after this time. */
@Prop({ required: true, expires: 0 })
expireAt: Date;
@Prop({ default: null })
usedAt?: Date | null;
}
export type CaptchaChallengeDocument = HydratedDocument<CaptchaChallenge>;
export const CaptchaChallengeSchema =
SchemaFactory.createForClass(CaptchaChallenge);

View File

@@ -30,6 +30,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
@@ -93,7 +94,7 @@ export class ClaimRequestManagementController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-green-cards", destination: "./files/car-green-cards",
@@ -126,9 +127,7 @@ export class ClaimRequestManagementController {
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Get("required-documents-status/:claimRequestID") @Get("required-documents-status/:claimRequestID")
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
async getRequiredDocumentsStatus( async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
@Param("claimRequestID") requestId: string,
) {
return await this.claimRequestManagementService.getRequiredDocumentsStatus( return await this.claimRequestManagementService.getRequiredDocumentsStatus(
requestId, requestId,
); );
@@ -153,7 +152,7 @@ export class ClaimRequestManagementController {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-required-documents/", destination: "./files/claim-required-documents/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -206,7 +205,7 @@ export class ClaimRequestManagementController {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-parts/", destination: "./files/car-parts/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -257,7 +256,7 @@ export class ClaimRequestManagementController {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-capture-videos/", destination: "./files/car-capture-videos/",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -292,10 +291,7 @@ export class ClaimRequestManagementController {
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Get("request/:claimRequestId") @Get("request/:claimRequestId")
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
myRequests( myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
@Param("claimRequestId") requestId: string,
@CurrentUser() user,
) {
return this.claimRequestManagementService.requestDetails(requestId, user); return this.claimRequestManagementService.requestDetails(requestId, user);
} }
@@ -305,7 +301,7 @@ export class ClaimRequestManagementController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-sign", destination: "./files/claim-sign",
@@ -330,12 +326,12 @@ export class ClaimRequestManagementController {
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return await this.claimRequestManagementService.submitUserReply( // return await this.claimRequestManagementService.submitUserReply(
requestId, // requestId,
body, // body,
file, // file,
user, // user,
); // );
} }
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@@ -386,7 +382,7 @@ export class ClaimRequestManagementController {
callback(null, filename); callback(null, filename);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadDocuments( async uploadDocuments(
@@ -449,7 +445,7 @@ export class ClaimRequestManagementController {
callback(null, filename); callback(null, filename);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadFactorForPart( async uploadFactorForPart(

View File

@@ -11,7 +11,10 @@ import { ClaimRequestManagementService } from "./claim-request-management.servic
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service"; import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema"; import {
ClaimCase,
ClaimCaseSchema,
} from "./entites/schema/claim-cases.schema";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -47,9 +50,11 @@ import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { MediaPolicyModule } from "src/media-policy/media-policy.module";
import { HttpModule } from "@nestjs/axios";
@Module({ @Module({
imports: [ imports: [
HttpModule,
PublicIdModule, PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,

View File

@@ -18,13 +18,14 @@ import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger"; import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "path"; import { extname } from "node:path";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { import {
@@ -289,7 +290,7 @@ export class ClaimRequestManagementV2Controller {
@ApiResponse({ status: 409, description: "Already signed" }) @ApiResponse({ status: 409, description: "Already signed" })
@UseInterceptors( @UseInterceptors(
FileInterceptor("sign", { FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-sign", destination: "./files/claim-sign",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -566,7 +567,7 @@ Optional: upload car green card file in the same step.
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-required-document", destination: "./files/claim-required-document",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -706,7 +707,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-documents", destination: "./files/claim-documents",
@@ -814,7 +815,7 @@ Returns status of each item (uploaded/captured or not).
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-captures", destination: "./files/claim-captures",
@@ -934,7 +935,7 @@ Returns status of each item (uploaded/captured or not).
callback(null, `-${unique}-${file.originalname}`); callback(null, `-${unique}-${file.originalname}`);
}, },
}), }),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
}), }),
) )
async uploadFactorForPartV2( async uploadFactorForPartV2(
@@ -973,7 +974,7 @@ Returns status of each item (uploaded/captured or not).
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/car-capture-videos/", destination: "./files/car-capture-videos/",
filename: (req, file, callback) => { filename: (req, file, callback) => {

View File

@@ -67,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional({ type: [String] }) @ApiPropertyOptional({ type: [String] })
resendDocuments?: string[]; resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] }) @ApiPropertyOptional({
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>; description:
"Damaged parts the expert asked to re-capture (same shape as damagedParts: id, name, side, label_fa, catalogKey, captured, url).",
type: [Object],
})
resendCarParts?: Array<{
id?: number | null;
name?: string;
side?: string;
label_fa?: string;
label_en?: string;
catalogKey?: string;
key?: string;
captured?: boolean;
url?: string;
path?: string;
fileName?: string;
}>;
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' }) @ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
fulfilledAt?: Date; fulfilledAt?: Date;

View File

@@ -66,6 +66,13 @@ export class ClaimWorkflow {
@Prop({ type: ClaimPreLockQueueSnapshotSchema }) @Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot; preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
/**
* First damage expert who called review assign; kept after the 15m lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ClaimActorLockSchema })
assignedForReviewBy?: ClaimActorLock;
} }
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -159,5 +159,5 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>; export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

@@ -1,5 +1,5 @@
import { Prop, Schema } from "@nestjs/mongoose"; import { Prop, Schema } from "@nestjs/mongoose";
import { v4 as uuidv4 } from "uuid"; import { randomUUID } from "node:crypto";
@Schema({ versionKey: false, _id: false }) @Schema({ versionKey: false, _id: false })
export class ImageRequiredModel { export class ImageRequiredModel {
@@ -20,7 +20,7 @@ export class ImageRequiredModel {
constructor(claimFile: any[]) { constructor(claimFile: any[]) {
this.aroundTheCar.forEach((a) => { this.aroundTheCar.forEach((a) => {
Object.assign(a, { Object.assign(a, {
partId: uuidv4(), partId: randomUUID(),
imageId: null, imageId: null,
aiReport: {}, aiReport: {},
upload: false, upload: false,
@@ -28,7 +28,7 @@ export class ImageRequiredModel {
}); });
this.selectPartOfCar = claimFile.map((c, idx) => this.selectPartOfCar = claimFile.map((c, idx) =>
Object.assign(c, { Object.assign(c, {
partId: uuidv4(), partId: randomUUID(),
aiReport: {}, aiReport: {},
imageId: null, imageId: null,
upload: false, upload: false,

View File

@@ -8,6 +8,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto"; import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto"; import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto"; import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
@@ -83,7 +84,7 @@ export class RegistrarClaimV1Controller {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-documents", destination: "./files/claim-documents",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
@@ -110,7 +111,7 @@ export class RegistrarClaimV1Controller {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/claim-captures", destination: "./files/claim-captures",
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),

View File

@@ -23,14 +23,19 @@ import { ClientDbService } from "./entities/db-service/client.db.service";
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the * `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
* gate is enforced even for older / unconfigured clients. * gate is enforced even for older / unconfigured clients.
*/ */
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7; export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 5;
/** /**
* Media kinds we enforce upload-size bounds for. Each kind has its own * Media kinds we enforce upload-size bounds for. Each kind has its own
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional * default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
* per-client override under `settings.media.<kind>`. * per-client override under `settings.media.<kind>`.
*/ */
export type MediaKind = "video" | "image" | "voice"; export type MediaKind =
| "video"
| "image"
| "voice"
| "chassisNumber"
| "carPlate";
export interface MediaLimits { export interface MediaLimits {
/** Inclusive lower bound. `0` allows any size from zero up. */ /** Inclusive lower bound. `0` allows any size from zero up. */
@@ -47,17 +52,39 @@ export interface MediaLimits {
* receive. Per-client `maxBytes` cannot legitimately go above its route's * receive. Per-client `maxBytes` cannot legitimately go above its route's
* multer ceiling, so the defaults below are kept at-or-below those. * multer ceiling, so the defaults below are kept at-or-below those.
*/ */
export const DEFAULT_MEDIA_MIN_BYTES = 10; // 10 Byte
export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = { export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 }, video: {
image: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, minBytes: DEFAULT_MEDIA_MIN_BYTES,
voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 }, maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
image: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
voice: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
carPlate: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
chassisNumber: {
minBytes: DEFAULT_MEDIA_MIN_BYTES,
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
},
}; };
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */ /** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = { export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
video: 50 * 1024 * 1024, video: DEFAULT_MEDIA_MAX_BYTES,
image: 10 * 1024 * 1024, image: DEFAULT_MEDIA_MAX_BYTES,
voice: 10 * 1024 * 1024, voice: DEFAULT_MEDIA_MAX_BYTES,
carPlate: DEFAULT_MEDIA_MAX_BYTES,
chassisNumber: DEFAULT_MEDIA_MAX_BYTES,
}; };
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1; export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
@@ -173,13 +200,19 @@ export class ClientService {
_id: new Types.ObjectId(idString), _id: new Types.ObjectId(idString),
}); });
const configured = client?.settings?.carBodyAccidentMaxAgeDays; const configured = client?.settings?.carBodyAccidentMaxAgeDays;
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { if (
typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
) {
return configured; return configured;
} }
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS; return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
} }
private resolveClientObjectId(clientKey: string | Types.ObjectId): Types.ObjectId { private resolveClientObjectId(
clientKey: string | Types.ObjectId,
): Types.ObjectId {
const raw = String(clientKey ?? "").trim(); const raw = String(clientKey ?? "").trim();
if (!Types.ObjectId.isValid(raw)) { if (!Types.ObjectId.isValid(raw)) {
throw new BadRequestException("Invalid client key"); throw new BadRequestException("Invalid client key");
@@ -281,7 +314,8 @@ export class ClientService {
const $set: Record<string, unknown> = {}; const $set: Record<string, unknown> = {};
if (body.carBodyAccidentMaxAgeDays !== undefined) { if (body.carBodyAccidentMaxAgeDays !== undefined) {
$set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays; $set["settings.carBodyAccidentMaxAgeDays"] =
body.carBodyAccidentMaxAgeDays;
} }
if (body.media) { if (body.media) {

View File

@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export type ExpertFileAssignStatus =
| "assigned"
| "already_assigned_to_you"
| "locked"
| "unavailable";
export class ExpertFileAssignResultDto {
@ApiProperty({ example: true })
success: boolean;
@ApiProperty({
enum: ["assigned", "already_assigned_to_you", "locked", "unavailable"],
example: "assigned",
})
status: ExpertFileAssignStatus;
@ApiPropertyOptional({
example: "You already started processing this request",
})
message?: string;
@ApiPropertyOptional({
description: "When the expert was assigned (ISO 8601).",
example: "2026-05-24T12:00:00.000Z",
})
assignedAt?: string;
@ApiPropertyOptional({
description: "Present when status is locked (another expert).",
})
lockedBy?: {
actorId: string;
actorName?: string;
lockedAt?: string;
};
}

View File

@@ -26,7 +26,6 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { ClientKey } from "src/decorators/clientKey.decorator"; import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { LoggingInterceptor } from "src/interceptor/logging.interceptors";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { import {
ResendFirstPartyDto, ResendFirstPartyDto,
@@ -135,7 +134,6 @@ export class ExpertBlameController {
return this.expertBlameService.streamVideo(requestId); return this.expertBlameService.streamVideo(requestId);
} }
@UseInterceptors(LoggingInterceptor)
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Get("voice/:requestId/:voiceId") @Get("voice/:requestId/:voiceId")
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)

View File

@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
HttpException, HttpException,
Injectable, Injectable,
@@ -23,6 +24,16 @@ import {
} from "src/helpers/expert-portfolio"; } from "src/helpers/expert-portfolio";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
blameWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -71,13 +82,11 @@ interface CheckedRequestEntry {
[key: string]: any; [key: string]: any;
} }
function statementToFormKey( function statementToFormKey(statement?: {
statement?: { admitsGuilt?: boolean;
admitsGuilt?: boolean; claimsDamage?: boolean;
claimsDamage?: boolean; acceptsExpertOpinion?: boolean;
acceptsExpertOpinion?: boolean; }): string | null {
},
): string | null {
if (!statement) return null; if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty"; if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged"; if (statement.claimsDamage) return "imDamaged";
@@ -104,7 +113,7 @@ export class ExpertBlameService {
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) { } ) {}
/** Load immutable profile fields from `expert` for the acting field expert. */ /** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) { private async snapshotFieldExpert(actorId: string) {
@@ -140,7 +149,8 @@ export class ExpertBlameService {
const requestsNeedingExpert = []; const requestsNeedingExpert = [];
for (const request of allRequests) { for (const request of allRequests) {
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm; const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
const secondPartyForm = request.secondPartyDetails?.secondPartyInitialForm; const secondPartyForm =
request.secondPartyDetails?.secondPartyInitialForm;
if (!firstPartyForm || !secondPartyForm) { if (!firstPartyForm || !secondPartyForm) {
continue; // Skip if forms are not complete continue; // Skip if forms are not complete
@@ -274,6 +284,14 @@ export class ExpertBlameService {
buckets.all++; buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1; buckets[key] = (buckets[key] ?? 0) + 1;
} }
// Adding extra fields for front-end
buckets["PENDING_ON_USER"] =
buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] +
buckets[CaseStatus.WAITING_FOR_SIGNATURES];
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT];
return buckets; return buckets;
} }
@@ -298,44 +316,52 @@ export class ExpertBlameService {
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision) // 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 3. Requests decided by current expert // 3. Requests decided by current expert
// 4. Expert-initiated: only the initiating field expert sees them // 4. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => { const visibleCases = (allCases as Record<string, unknown>[]).filter(
if (!blameCaseAccessibleToExpert(doc, actor)) { (doc) => {
return false; if (!blameCaseAccessibleToExpert(doc, actor)) {
} return false;
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId) {
if (String(initiatedByFieldExpertId) !== expertId) {
return false; // Only the initiating field expert can see this file
} }
return true; // Initiating expert can see their expert-initiated file
}
const status = doc.status as string; const expertInitiated = doc.expertInitiated === true;
const decision = doc.expert as any; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
const decidedByExpertId = decision?.decision?.decidedByExpertId;
const hasDecision = !!decision?.decision;
// Fresh request (no decision yet) if (expertInitiated && initiatedByFieldExpertId) {
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) { if (String(initiatedByFieldExpertId) !== expertId) {
return true; return false; // Only the initiating field expert can see this file
} }
return true; // Initiating expert can see their expert-initiated file
}
// Request decided by current expert const status = doc.status as string;
if (decidedByExpertId && String(decidedByExpertId) === expertId) { const decision = doc.expert as any;
return true; const decidedByExpertId = decision?.decision?.decidedByExpertId;
} const hasDecision = !!decision?.decision;
// Locked by current expert but no decision yet // Fresh request (no decision yet)
const lockedBy = decision?.resend?.requestedByExpertId || (doc.workflow as any)?.lockedBy?.actorId; if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) {
if (status === CaseStatus.WAITING_FOR_EXPERT && lockedBy && String(lockedBy) === expertId) { return true;
return true; }
}
return false; // Request decided by current expert
}); if (decidedByExpertId && String(decidedByExpertId) === expertId) {
return true;
}
// Locked by current expert but no decision yet
const lockedBy =
decision?.resend?.requestedByExpertId ||
(doc.workflow as any)?.lockedBy?.actorId;
if (
status === CaseStatus.WAITING_FOR_EXPERT &&
lockedBy &&
String(lockedBy) === expertId
) {
return true;
}
return false;
},
);
const staleIds = new Set<string>(); const staleIds = new Set<string>();
for (const doc of visibleCases) { for (const doc of visibleCases) {
@@ -346,7 +372,9 @@ export class ExpertBlameService {
} }
} }
await Promise.all( await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)), [...staleIds].map((id) =>
this.expireBlameCaseWorkflowLockV2IfStale(id),
),
); );
for (const doc of visibleCases) { for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue; if (!staleIds.has(String(doc._id))) continue;
@@ -359,29 +387,32 @@ export class ExpertBlameService {
} }
} }
const paged = applyListQueryV2(visibleCases, { const paged = applyListQueryV2(
publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""), visibleCases,
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt, {
requestNo: (doc) => publicId: (doc) =>
String( String((doc as { publicId?: string }).publicId ?? ""),
(doc as { requestNo?: string }).requestNo ?? createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
(doc as { publicId?: string }).publicId ?? requestNo: (doc) =>
"", String(
), (doc as { requestNo?: string }).requestNo ??
status: (doc) => String((doc as { status?: string }).status ?? ""), (doc as { publicId?: string }).publicId ??
searchExtras: (doc) => { "",
const d = doc as { ),
_id?: unknown; status: (doc) => String((doc as { status?: string }).status ?? ""),
blameStatus?: string; searchExtras: (doc) => {
type?: string; const d = doc as {
}; _id?: unknown;
return [ blameStatus?: string;
String(d._id ?? ""), type?: string;
d.blameStatus, };
d.type, return [String(d._id ?? ""), d.blameStatus, d.type].filter(
].filter(Boolean) as string[]; Boolean,
) as string[];
},
}, },
}, query); query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) => const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc), this.mapBlameRequestToListItemV2(doc),
@@ -396,7 +427,10 @@ export class ExpertBlameService {
}); });
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error)); this.logger.error(
"findAllV2 failed",
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases", error instanceof Error ? error.message : "Failed to list blame cases",
); );
@@ -406,8 +440,12 @@ export class ExpertBlameService {
private mapBlameRequestToListItemV2( private mapBlameRequestToListItemV2(
doc: Record<string, unknown>, doc: Record<string, unknown>,
): AllRequestDtoV2 { ): AllRequestDtoV2 {
const createdAt = doc.createdAt ? new Date(doc.createdAt as string) : new Date(); const createdAt = doc.createdAt
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string) : new Date(); ? new Date(doc.createdAt as string)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string)
: new Date();
const [date, time] = toJalaliDateAndTime(createdAt); const [date, time] = toJalaliDateAndTime(createdAt);
const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt); const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
const workflow = (doc.workflow ?? {}) as Record<string, unknown>; const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
@@ -453,17 +491,16 @@ export class ExpertBlameService {
}, },
partiesVehicles: { partiesVehicles: {
firstPartyVehicle: firstPartyVehicle:
firstParty?.vehicle?.name || firstParty?.vehicle?.name ||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam || firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
firstParty?.vehicle?.inquiry?.mapped?.CarName || firstParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
secondPartyVehicle: secondPartyVehicle:
secondParty?.vehicle?.name || secondParty?.vehicle?.name ||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam || secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName || secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"", "",
} },
}; };
} }
@@ -483,9 +520,7 @@ export class ExpertBlameService {
} }
const la = doc.workflow.lockedAt; const la = doc.workflow.lockedAt;
if (!la) return true; if (!la) return true;
return ( return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
);
} }
/** /**
@@ -502,16 +537,12 @@ export class ExpertBlameService {
const lockedAt = request.workflow.lockedAt; const lockedAt = request.workflow.lockedAt;
const expiredAt = request.workflow.expiredAt; const expiredAt = request.workflow.expiredAt;
if ( if (expiredAt && Date.now() < new Date(expiredAt as Date).getTime()) {
expiredAt &&
Date.now() < new Date(expiredAt as Date).getTime()
) {
return; return;
} }
if ( if (
lockedAt && lockedAt &&
Date.now() < Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) { ) {
return; return;
} }
@@ -531,10 +562,18 @@ export class ExpertBlameService {
); );
} }
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
request.workflow,
);
const expireSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const cleared = await this.blameRequestDbService.findOneAndUpdate( const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any, filter as any,
{ {
$set: { "workflow.locked": false }, $set: expireSet,
$unset: { $unset: {
"workflow.lockedAt": "", "workflow.lockedAt": "",
"workflow.expiredAt": "", "workflow.expiredAt": "",
@@ -652,8 +691,12 @@ export class ExpertBlameService {
requestId: String(current._id), requestId: String(current._id),
eventType: ExpertFileActivityType.UNCHECKED, eventType: ExpertFileActivityType.UNCHECKED,
tenantId: tenantId:
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") || String(
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""), current.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
current.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`, idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
}); });
@@ -806,16 +849,20 @@ export class ExpertBlameService {
* Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence. * Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence.
* Access control: Only allows viewing fresh requests or requests decided by current expert. * Access control: Only allows viewing fresh requests or requests decided by current expert.
*/ */
async findOneV2(requestId: string, actor: any): Promise<Record<string, unknown>> { async findOneV2(
requestId: string,
actor: any,
): Promise<Record<string, unknown>> {
try { try {
requireActorClientKey(actor); // requireActorClientKey(actor);
const actorId = actor.sub; const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); const doc =
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) { if (!doc) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(doc, actor); // assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string; const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) { if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -826,14 +873,20 @@ export class ExpertBlameService {
// Access control // Access control
const expertInitiated = doc.expertInitiated === true; const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) { if (
expertInitiated &&
initiatedByFieldExpertId &&
String(initiatedByFieldExpertId) !== actorId
) {
throw new ForbiddenException( throw new ForbiddenException(
"Only the field expert who created this file can view and review it.", "Only the field expert who created this file can view and review it.",
); );
} }
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) { if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined; const w = doc.workflow as
| { lockedBy?: { actorId?: unknown } }
| undefined;
const lockerId = String(w?.lockedBy?.actorId ?? ""); const lockerId = String(w?.lockedBy?.actorId ?? "");
if (lockerId && lockerId !== actorId) { if (lockerId && lockerId !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -872,14 +925,18 @@ export class ExpertBlameService {
const evidence = party.evidence as Record<string, unknown>; const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) { if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
} }
if (evidence.voices && Array.isArray(evidence.voices)) { if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = []; const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) { for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
} }
evidence.voiceUrls = voiceUrls; evidence.voiceUrls = voiceUrls;
@@ -887,9 +944,12 @@ export class ExpertBlameService {
} }
// Date formatting // Date formatting
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date(); const createdAt = doc.createdAt
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); ? new Date(doc.createdAt as string | number)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number)
: new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
@@ -916,12 +976,13 @@ export class ExpertBlameService {
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details", error instanceof Error
? error.message
: "Failed to get blame case details",
); );
} }
} }
async lockRequest(requestId: string, actorDetail) { async lockRequest(requestId: string, actorDetail) {
const existing = await this.requestManagementDbService.findOne(requestId); const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) { if (!existing) {
@@ -937,7 +998,8 @@ export class ExpertBlameService {
const lockedByCurrent = const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub); String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired = const isLockExpired =
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime(); !!existing.unlockTime &&
Date.now() >= new Date(existing.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file. // Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) { if (isLocked && lockedByCurrent && !isLockExpired) {
@@ -990,8 +1052,12 @@ export class ExpertBlameService {
requestId: String(requestId), requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED, eventType: ExpertFileActivityType.CHECKED,
tenantId: tenantId:
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") || String(
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""), updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`, idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
}); });
@@ -1000,118 +1066,254 @@ export class ExpertBlameService {
} }
/** /**
* V2: Lock blame case for 15 minutes (blameCases collection). * Assign the current field expert to a blame case for review (V2).
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt. * Free cases are locked atomically; already-assigned-to-self is idempotent.
* Checks if existing lock is expired and allows re-locking.
*/ */
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { async assignBlameCaseForReviewV2(
try { requestId: string,
const now = new Date(); actor: { sub: string; fullName?: string; clientKey?: string },
): Promise<ExpertFileAssignResultDto> {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); const request = await this.blameRequestDbService.findById(requestId);
// First, check the current state if (!request) {
const request = await this.blameRequestDbService.findById(requestId); throw new NotFoundException("Request not found");
if (!request) { }
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actorDetail); assertBlameCaseForExpertTenant(request, actor);
// Validate request is available for expert review if (request.type === BlameRequestType.CAR_BODY) {
if ( throw new BadRequestException({
request.status !== CaseStatus.WAITING_FOR_EXPERT || success: false,
request.blameStatus !== BlameStatus.DISAGREEMENT status: "unavailable" satisfies ExpertFileAssignStatus,
) { message: "CAR_BODY cases do not require expert review",
throw new BadRequestException(
"Request is not available for expert review",
);
}
// Expert-initiated: only the initiating field expert can lock and review
if (
request.expertInitiated &&
request.initiatedByFieldExpertId &&
String(request.initiatedByFieldExpertId) !== actorDetail.sub
) {
throw new ForbiddenException(
"Only the field expert who created this file can lock and review it.",
);
}
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
if (
request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
) {
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
}
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
// Lock the request (either unlocked or expired lock)
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
{
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": new Date(
now.getTime() + this.blameV2LockTtlMs,
),
"workflow.lockedBy": {
actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert",
actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
},
);
if (!updateResult) {
throw new InternalServerErrorException("Failed to lock the request");
}
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
}); });
}
if (request.type === BlameRequestType.THIRD_PARTY) { if (
const phones = (request.parties || []) request.status !== CaseStatus.WAITING_FOR_EXPERT ||
.map((p: any) => p?.person?.phoneNumber) request.blameStatus !== BlameStatus.DISAGREEMENT
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0); ) {
const expertLastName = throw new BadRequestException({
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; success: false,
for (const phone of phones) { status: "unavailable" satisfies ExpertFileAssignStatus,
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( message: "Request is not available for expert review",
{ });
receptor: phone, }
fileKind: "blame",
publicId: request.publicId, if (
expertLastName, request.expertInitiated &&
}, request.initiatedByFieldExpertId &&
); String(request.initiatedByFieldExpertId) !== actor.sub
} ) {
throw new ForbiddenException(
"Only the field expert who created this file can review it",
);
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
request.workflow,
request.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
request.workflow,
),
);
}
const lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
return {
success: true,
status: "already_assigned_to_you",
message: "You already started processing this request",
assignedAt: request.workflow?.lockedAt
? new Date(request.workflow.lockedAt as Date).toISOString()
: undefined,
};
}
const now = new Date();
const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorRole: "expert" as const,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const lockUpdate: {
$set: Record<string, unknown>;
$push: Record<string, unknown>;
} = {
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
"workflow.lockedBy": lockedByPayload,
},
$push: {
history: {
type: "BLAME_ASSIGNED",
actor: {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorType: "field_expert",
},
timestamp: now,
metadata: { note: "Expert assigned via review assign endpoint" },
},
},
};
if (!request.workflow?.assignedForReviewBy) {
lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload;
}
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
status: CaseStatus.WAITING_FOR_EXPERT,
blameStatus: BlameStatus.DISAGREEMENT,
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
if (request.expertInitiated) {
assignFilter.initiatedByFieldExpertId = expertOid;
}
const updated = await this.blameRequestDbService.findOneAndUpdate(
assignFilter as any,
lockUpdate,
{ new: true },
);
if (!updated) {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const latest = await this.blameRequestDbService.findById(requestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
} }
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(latest) &&
otherId &&
otherId !== actor.sub
) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request could not be assigned",
});
}
return { _id: requestId, lock: true }; await this.recordBlameExpertActivity({
expertId: String(actor.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(updated.parties?.[0]?.person?.clientId ?? "") ||
String(updated.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:checked:${actor.sub}`,
});
if (updated.type === BlameRequestType.THIRD_PARTY) {
const phones = (updated.parties || [])
.map((p: any) => p?.person?.phoneNumber)
.filter(
(p: unknown): p is string => typeof p === "string" && p.length > 0,
);
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: phone,
fileKind: "blame",
publicId: updated.publicId,
expertLastName,
},
);
}
}
this.logger.log(
`Blame case ${requestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
);
return {
success: true,
status: "assigned",
assignedAt: now.toISOString(),
};
}
/**
* V2: Lock blame case for 15 minutes (blameCases collection).
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
*/
async lockRequestV2(
requestId: string,
actorDetail: any,
): Promise<{ _id: string; lock: boolean; message?: string }> {
try {
const result = await this.assignBlameCaseForReviewV2(
requestId,
actorDetail,
);
return {
_id: requestId,
lock: true,
message: result.message,
};
} catch (error) { } catch (error) {
if (error instanceof ConflictException) {
const body = error.getResponse();
throw new BadRequestException(body);
}
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
"lockRequestV2 failed", "lockRequestV2 failed",
@@ -1347,7 +1549,9 @@ export class ExpertBlameService {
parties: partyResendRequests.map((p) => ({ parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId), partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[], requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]), requestedItemsUi: buildResendItemsWithUi(
p.requestedItems as string[],
),
description: p.description || undefined, description: p.description || undefined,
})), })),
}; };
@@ -1359,7 +1563,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to request document resend", error instanceof Error
? error.message
: "Failed to request document resend",
); );
} }
} }
@@ -1541,15 +1747,14 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply", error instanceof Error
? error.message
: "Failed to submit expert reply",
); );
} }
} }
async submitInPerson( async submitInPerson(requestId: string, actorId: string) {
requestId: string,
actorId: string,
) {
try { try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
@@ -1655,7 +1860,6 @@ export class ExpertBlameService {
requestId: String(request._id), requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES, status: CaseStatus.WAITING_FOR_SIGNATURES,
}; };
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
@@ -1664,7 +1868,9 @@ export class ExpertBlameService {
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply", error instanceof Error
? error.message
: "Failed to submit expert reply",
); );
} }
} }
@@ -1989,5 +2195,4 @@ export class ExpertBlameService {
return updated; return updated;
} }
} }

View File

@@ -5,11 +5,13 @@ import {
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
Param, Param,
Post,
Put, Put,
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
@@ -79,7 +81,36 @@ export class ExpertBlameV2Controller {
} }
} }
@Post(":id/assign")
@ApiOperation({
summary: "Check availability and assign blame case to this expert",
description:
"Call before opening a case from the list. Returns `assigned` when the case was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Does not replace PUT lock — use this for list UI availability.",
})
@ApiParam({ name: "id", description: "Blame case request id" })
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
@ApiResponse({
status: 409,
description: "Another expert is processing this case",
type: ExpertFileAssignResultDto,
})
async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) {
try {
return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to assign blame case",
);
}
}
@Put("lock/:id") @Put("lock/:id")
@ApiOperation({
summary: "Lock blame case for review (legacy)",
description:
"Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.",
})
@ApiParam({ name: "id", description: "Blame case request id" }) @ApiParam({ name: "id", description: "Blame case request id" })
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) { async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
try { try {

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum'; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto'; import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
export class ClaimDetailV2ResponseDto { export class ClaimDetailV2ResponseDto {
@ApiProperty() @ApiProperty()
@@ -21,7 +21,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional() @ApiPropertyOptional()
nextStep?: string; nextStep?: string;
@ApiProperty({ description: 'Whether claim is locked' }) @ApiProperty({ description: "Whether claim is locked" })
locked: boolean; locked: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -47,64 +47,70 @@ export class ClaimDetailV2ResponseDto {
}; };
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Blame file type from linked blame case', description: "Blame file type from linked blame case",
enum: BlameRequestType, enum: BlameRequestType,
}) })
blameRequestType?: BlameRequestType; blameRequestType?: BlameRequestType;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)', "CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
example: { car: true, object: false }, example: { car: true, object: false },
}) })
carBodyFirstForm?: { car?: boolean; object?: boolean }; carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' }) @ApiPropertyOptional({ description: "Blame request ID", example: "507f..." })
blameRequestId?: string; blameRequestId?: string;
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' }) @ApiPropertyOptional({
description: "Blame request number",
example: "BL12345",
})
blameRequestNo?: string; blameRequestNo?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Selected outer damaged parts (id, name, side, label_fa)', description: "Selected outer damaged parts (id, name, side, label_fa)",
type: [DamageSelectedPartV2BodyDto], type: [DamageSelectedPartV2BodyDto],
}) })
selectedParts?: DamageSelectedPartV2BodyDto[]; selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' }) @ApiPropertyOptional({ description: "Selected other damaged parts" })
otherParts?: string[]; otherParts?: string[];
@ApiPropertyOptional({ description: 'Required documents status' }) @ApiPropertyOptional({ description: "Required documents status" })
requiredDocuments?: Record<string, { requiredDocuments?: Record<
uploaded: boolean; string,
fileId?: string; {
fileName?: string; uploaded: boolean;
filePath?: string; fileId?: string;
}>; fileName?: string;
filePath?: string;
}
>;
@ApiPropertyOptional({ description: 'Car angles captured' }) @ApiPropertyOptional({ description: "Car angles captured" })
carAngles?: Record<string, { captured: boolean; url?: string }>; carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)', "Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)",
type: 'array', type: "array",
items: { items: {
type: 'object', type: "object",
properties: { properties: {
index: { type: 'number' }, index: { type: "number" },
partId: { partId: {
type: 'number', type: "number",
nullable: true, nullable: true,
description: description:
'Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop', "Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop",
}, },
id: { type: 'number', nullable: true }, id: { type: "number", nullable: true },
name: { type: 'string' }, name: { type: "string" },
side: { type: 'string' }, side: { type: "string" },
label_fa: { type: 'string' }, label_fa: { type: "string" },
captured: { type: 'boolean' }, captured: { type: "boolean" },
url: { type: 'string' }, url: { type: "string" },
}, },
}, },
}) })
@@ -121,24 +127,25 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'True when user uploaded all required factors and the case awaits expert approve/reject.', "True when user uploaded all required factors and the case awaits expert approve/reject.",
}) })
awaitingFactorValidation?: boolean; awaitingFactorValidation?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Slice of `claim.evaluation` exposed to the damage expert. ' + "Slice of `claim.evaluation` exposed to the damage expert. " +
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' + "`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
'awaiting factor validation. `ownerInsurerApproval` and ' + "awaiting factor validation. `ownerInsurerApproval` and " +
'`ownerPricedPartsApproval` are returned whenever they exist on the ' + "`ownerPricedPartsApproval` are returned whenever they exist on the " +
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' + "claim — each carries `signLink` (resolved from `signDetailId`) so the " +
'front-end can render the user signature directly. Likewise, ' + "front-end can render the user signature directly. Likewise, " +
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' + "`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
'expose `signLink` when a user comment signature is present.', "expose `signLink` when a user comment signature is present.",
}) })
evaluation?: { evaluation?: {
damageExpertReply?: unknown; damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown; damageExpertReplyFinal?: unknown;
damageExpertResend: unknown;
ownerInsurerApproval?: { ownerInsurerApproval?: {
agree: boolean; agree: boolean;
branchId?: string; branchId?: string;
@@ -166,7 +173,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.', "Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.",
}) })
objection?: { objection?: {
objectionParts?: Array<Record<string, unknown>>; objectionParts?: Array<Record<string, unknown>>;
@@ -176,7 +183,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).', "Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).",
}) })
videoCapture?: { videoCapture?: {
id: string; id: string;
@@ -187,18 +194,19 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.', "Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
}) })
blameCase?: Record<string, unknown>; blameCase?: Record<string, unknown>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.', "Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.",
}) })
blameExpertDecision?: Record<string, unknown>; blameExpertDecision?: Record<string, unknown>;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).', description:
"Claim payout / banking metadata from the claim file (e.g. Sheba).",
}) })
money?: { money?: {
sheba?: string; sheba?: string;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common"; import { Body, Controller, Get, Header, Param, Headers, Patch, Post, Put, UseGuards, Query, Res } from "@nestjs/common";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -15,6 +15,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
@@ -161,6 +162,29 @@ export class ExpertClaimV2Controller {
); );
} }
@Post("assign/:claimRequestId")
@ApiOperation({
summary: "Check availability and assign claim to this damage expert",
description:
"Call before opening a case from the list. Returns `assigned` when the claim was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Works for both the damage-review queue (`WAITING_FOR_DAMAGE_EXPERT`) and factor-validation queue.",
})
@ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
@ApiResponse({
status: 409,
description: "Another expert is processing this claim",
type: ExpertFileAssignResultDto,
})
async assignClaimForReviewV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.assignClaimForReviewV2(
claimRequestId,
actor,
);
}
@Put("lock/:claimRequestId") @Put("lock/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Lock a claim request for review", summary: "Lock a claim request for review",
@@ -168,7 +192,7 @@ export class ExpertClaimV2Controller {
"Lockable in two queues:\n" + "Lockable in two queues:\n" +
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" + "1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" + "2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.", "Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2( async lockClaimRequestV2(

View File

@@ -1,10 +1,30 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media"; import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
import { normalizeDamageSelectedParts } from "./outer-damage-parts"; import {
catalogLikeKeyFromPart,
DamageSelectedPartV2,
normalizeDamageSelectedParts,
partLookupKey,
} from "./outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys"; import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export type ExpertResendCarPartV2 = {
id?: number | null;
name: string;
side: string;
label_fa: string;
label_en?: string;
catalogKey?: string;
/** Same as `catalogKey` — kept for clients that use `key` on capture/resend APIs. */
key?: string;
captured: boolean;
url?: string;
path?: string;
fileName?: string;
};
export function resendRequestHasPayload(r: { export function resendRequestHasPayload(r: {
resendDescription?: string; resendDescription?: string;
resendDocuments?: unknown[]; resendDocuments?: unknown[];
@@ -34,18 +54,161 @@ export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string
return [...new Set(keys)]; return [...new Set(keys)];
} }
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */ /** Capture / finalize lookup keys for expert-requested damaged parts. */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] { export function normalizeResendPartKeys(
parts: unknown[] | undefined,
carType?: ClaimVehicleTypeV2,
selectedParts?: unknown,
): string[] {
if (!Array.isArray(parts)) return []; if (!Array.isArray(parts)) return [];
const selectedNorm = normalizeDamageSelectedParts(
selectedParts,
carType,
);
const keys: string[] = []; const keys: string[] = [];
for (const p of parts) { for (const p of parts) {
const enriched =
normalizeDamageSelectedParts([p], carType)[0] ||
matchResendPartFromSelected(p, selectedNorm);
if (enriched) {
const ck =
enriched.catalogKey?.trim() ||
catalogLikeKeyFromPart(enriched);
if (ck) keys.push(ck);
continue;
}
if (typeof p === "string" && p.trim()) keys.push(p.trim()); if (typeof p === "string" && p.trim()) keys.push(p.trim());
else if (p && typeof p === "object" && "key" in (p as object)) { else if (p && typeof p === "object" && "key" in (p as object)) {
const k = String((p as { key?: string }).key || "").trim(); const k = String((p as { key?: string }).key || "").trim();
if (k) keys.push(k); if (k) keys.push(k);
} }
} }
return keys; return [...new Set(keys)];
}
function matchResendPartFromSelected(
raw: unknown,
selectedNorm: DamageSelectedPartV2[],
): DamageSelectedPartV2 | undefined {
if (!raw || typeof raw !== "object" || !selectedNorm.length) return undefined;
const o = raw as { id?: number };
if (typeof o.id === "number" && Number.isFinite(o.id)) {
return selectedNorm.find((sp) => sp.id === Math.trunc(o.id));
}
return undefined;
}
function resolveResendPartRow(
raw: unknown,
carType?: ClaimVehicleTypeV2,
selectedNorm?: DamageSelectedPartV2[],
): DamageSelectedPartV2 {
const fromRaw = normalizeDamageSelectedParts([raw], carType)[0];
if (fromRaw?.label_fa && fromRaw.name) return fromRaw;
const fromSelected = matchResendPartFromSelected(raw, selectedNorm ?? []);
if (fromSelected) return fromSelected;
if (fromRaw) return fromRaw;
const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const fallbackKey =
typeof o.key === "string" && o.key.trim()
? o.key.trim()
: typeof raw === "string"
? String(raw).trim()
: "unknown";
return {
id: typeof o.id === "number" ? Math.trunc(o.id) : null,
name: typeof o.name === "string" ? o.name : fallbackKey,
side: typeof o.side === "string" ? o.side : "",
label_fa:
typeof o.label_fa === "string" && o.label_fa.trim()
? o.label_fa.trim()
: fallbackKey,
catalogKey:
typeof o.catalogKey === "string" ? o.catalogKey : undefined,
};
}
/** Enrich stored resend rows for claim detail / capture UIs (label_fa, catalogKey, capture state). */
export function mapExpertResendCarPartsForClient(
rawParts: unknown[] | undefined,
options: {
carType?: ClaimVehicleTypeV2;
selectedParts?: unknown;
damagedPartsData?: unknown;
buildUrl?: (path: string) => string;
} = {},
): ExpertResendCarPartV2[] {
if (!Array.isArray(rawParts) || rawParts.length === 0) return [];
const selectedNorm = normalizeDamageSelectedParts(
options.selectedParts,
options.carType,
);
return rawParts.map((raw) => {
const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, options.carType, selectedNorm);
const catalogKey =
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const captureKey = catalogKey || partLookupKey(sp);
const cap = getDamagedPartCaptureBlob(
options.damagedPartsData,
captureKey,
selectedNorm,
) as { url?: string; path?: string; fileName?: string } | undefined;
const capturedFromStore =
typeof stored.captured === "boolean" ? stored.captured : undefined;
const url =
cap?.url ||
(cap?.path && options.buildUrl ? options.buildUrl(cap.path) : undefined);
return {
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
...(typeof stored.label_en === "string" && stored.label_en.trim()
? { label_en: stored.label_en.trim() }
: {}),
catalogKey,
key: catalogKey,
captured: capturedFromStore ?? !!cap,
...(url ? { url } : {}),
...(cap?.path ? { path: cap.path } : {}),
...(cap?.fileName ? { fileName: cap.fileName } : {}),
};
});
}
/** Persistable snapshot for `evaluation.damageExpertResend.resendCarParts`. */
export function normalizeResendCarPartsForStorage(
rawParts: unknown[] | undefined,
carType?: ClaimVehicleTypeV2,
selectedParts?: unknown,
): Array<Record<string, unknown>> {
if (!Array.isArray(rawParts)) return [];
const selectedNorm = normalizeDamageSelectedParts(selectedParts, carType);
return rawParts.map((raw) => {
const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, carType, selectedNorm);
const catalogKey =
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const row: Record<string, unknown> = {
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
catalogKey,
key: catalogKey,
captured: false,
};
if (typeof stored.label_en === "string" && stored.label_en.trim()) {
row.label_en = stored.label_en.trim();
}
return row;
});
} }
function getRequiredDocEntry(claim: any, documentKey: string): unknown { function getRequiredDocEntry(claim: any, documentKey: string): unknown {
@@ -62,9 +225,10 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo
export function hasDamagedPartCapture(claim: any, partKey: string): boolean { export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts; const data = claim?.media?.damagedParts;
if (!data) return false; if (!data) return false;
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const norm = normalizeDamageSelectedParts( const norm = normalizeDamageSelectedParts(
claim?.damage?.selectedParts, claim?.damage?.selectedParts,
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined, carType,
claim?.damage?.selectedOuterParts, claim?.damage?.selectedOuterParts,
); );
return getDamagedPartCaptureBlob(data, partKey, norm) != null; return getDamagedPartCaptureBlob(data, partKey, norm) != null;
@@ -82,8 +246,13 @@ export function canFinalizeExpertResend(claim: any): boolean {
const r = claim.evaluation?.damageExpertResend; const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false; if (!r || r.fulfilledAt) return false;
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const docKeys = normalizeResendDocumentKeys(r.resendDocuments); const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
const partKeys = normalizeResendPartKeys(r.resendCarParts); const partKeys = normalizeResendPartKeys(
r.resendCarParts,
carType,
claim?.damage?.selectedParts,
);
if (docKeys.length === 0 && partKeys.length === 0) { if (docKeys.length === 0 && partKeys.length === 0) {
return String(r.resendDescription || "").trim() !== ""; return String(r.resendDescription || "").trim() !== "";
@@ -113,6 +282,13 @@ export function documentKeyAllowedForExpertResend(
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean { export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
const r = claim?.evaluation?.damageExpertResend; const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false; if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts)); const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const allowed = new Set(
normalizeResendPartKeys(
r.resendCarParts,
carType,
claim?.damage?.selectedParts,
),
);
return allowed.has(partKey); return allowed.has(partKey);
} }

View File

@@ -1,13 +1,9 @@
import * as jMoment from "jalali-moment";
/** /**
* Converts an ISO date string or Date to Jalali (Persian) date and time. * Converts an ISO date string or Date to Jalali (Persian) date and time.
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date * @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
* @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37'] * @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37']
*/ */
export function toJalaliDateAndTime( export function toJalaliDateAndTime(input: string | Date): [string, string] {
input: string | Date,
): [string, string] {
const d = new Date(input); const d = new Date(input);
const dateStr = toJalaliDate(d); const dateStr = toJalaliDate(d);
const timeStr = toJalaliTime(d); const timeStr = toJalaliTime(d);
@@ -32,8 +28,7 @@ export function jalaliToGregorianDate(
): string | null { ): string | null {
if (input === null || input === undefined) return null; if (input === null || input === undefined) return null;
const raw = const raw = typeof input === "number" ? String(input) : String(input).trim();
typeof input === "number" ? String(input) : String(input).trim();
if (!raw) return null; if (!raw) return null;
let year = 0; let year = 0;
@@ -58,20 +53,16 @@ export function jalaliToGregorianDate(
if (!year || !month || !day) return null; if (!year || !month || !day) return null;
// If the year already looks Gregorian (>= 1900), assume the caller already // If the year already looks Gregorian (>= 1900), just normalise formatting.
// sent a Gregorian date and just normalise the formatting.
if (year >= 1900) { if (year >= 1900) {
const mm = String(month).padStart(2, "0"); const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0"); const dd = String(day).padStart(2, "0");
const m = jMoment(`${year}-${mm}-${dd}`, "YYYY-MM-DD"); const d = new Date(`${year}-${mm}-${dd}`);
return m.isValid() ? m.format("YYYY-MM-DD") : null; if (isNaN(d.getTime())) return null;
return `${year}-${mm}-${dd}`;
} }
const mm = String(month).padStart(2, "0"); return jalaliPartsToGregorian(year, month, day);
const dd = String(day).padStart(2, "0");
const jm = jMoment(`${year}-${mm}-${dd}`, "jYYYY-jMM-jDD");
if (!jm.isValid()) return null;
return jm.format("YYYY-MM-DD");
} }
/** /**
@@ -95,6 +86,92 @@ export function toJalaliTime(d: Date): string {
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
} }
/**
* Converts Jalali year/month/day parts to a Gregorian `YYYY-MM-DD` string
* using the algorithmic conversion (no external deps).
* Returns `null` if the resulting Gregorian date is invalid.
*/
function jalaliPartsToGregorian(
jYear: number,
jMonth: number,
jDay: number,
): string | null {
// Jalali to Julian Day Number, then to Gregorian
// Algorithm: https://www.fourmilab.ch/documents/calendar/
const jy = jYear - 979;
const jm = jMonth - 1;
const jd = jDay - 1;
let jDayNo =
365 * jy + Math.floor(jy / 4) - Math.floor(jy / 100) + Math.floor(jy / 400);
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
for (let i = 0; i < jm; i++) {
jDayNo += jalaliMonthDays[i];
}
jDayNo += jd;
// Convert to Gregorian day number (offset from 1970-01-01 in Julian days)
const gDayNo = jDayNo + 79;
let gy = 1979 + 400 * Math.floor(gDayNo / 146097);
let days = gDayNo % 146097;
let leap = true;
if (days >= 36525) {
days--;
gy += 100 * Math.floor(days / 36524);
days %= 36524;
if (days >= 365) days++;
else leap = false;
}
gy += 4 * Math.floor(days / 1461);
days %= 1461;
if (days >= 366) {
leap = false;
days--;
gy += Math.floor(days / 365);
days %= 365;
}
const gregorianMonthDays = [
31,
leap ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
for (let i = 0; i < 12; i++) {
if (days < gregorianMonthDays[i]) {
gm = i + 1;
break;
}
days -= gregorianMonthDays[i];
}
const gd = days + 1;
const mm = String(gm).padStart(2, "0");
const dd = String(gd).padStart(2, "0");
const result = `${gy}-${mm}-${dd}`;
// Sanity check
const check = new Date(result);
if (isNaN(check.getTime())) return null;
return result;
}
const PERSIAN_TO_ENGLISH: Record<string, string> = { const PERSIAN_TO_ENGLISH: Record<string, string> = {
"۰": "0", "۰": "0",
"۱": "1", "۱": "1",

View File

@@ -3,8 +3,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
/** Blame: early workflow before expert queue / resend / signatures / terminals. */ /** Blame: early workflow before expert queue / resend / signatures / terminals. */
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([ const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
CaseStatus.OPEN, // CaseStatus.OPEN,
CaseStatus.WAITING_FOR_SECOND_PARTY, // CaseStatus.WAITING_FOR_SECOND_PARTY,
]); ]);
/** /**

View File

@@ -0,0 +1,77 @@
import type { ExpertFileAssignStatus } from "src/common/dto/expert-file-assign-result.dto";
export const BLAME_REVIEW_ASSIGNED_HISTORY_TYPE = "BLAME_ASSIGNED";
export const CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE = "CLAIM_ASSIGNED";
type WorkflowAssigneeRef = {
assignedForReviewBy?: { actorId?: unknown; actorName?: string };
lockedBy?: { actorId?: unknown; actorName?: string };
lockedAt?: Date | string;
};
type HistoryAssigneeRef = {
type?: string;
actor?: { actorId?: unknown };
timestamp?: Date | string;
};
/** Expert who first assigned via the review assign endpoint (survives lock TTL expiry). */
export function resolvePersistentReviewAssigneeId(
workflow: WorkflowAssigneeRef | undefined,
history: HistoryAssigneeRef[] | undefined,
assignedHistoryType: string,
): string {
const fromField = workflow?.assignedForReviewBy?.actorId;
if (fromField != null && String(fromField).length > 0) {
return String(fromField);
}
const fromHistory = history?.find(
(h) => h.type === assignedHistoryType,
)?.actor?.actorId;
if (fromHistory != null && String(fromHistory).length > 0) {
return String(fromHistory);
}
return "";
}
export function buildExpertAssignConflictForOtherReviewer(
assigneeId: string,
workflow?: WorkflowAssigneeRef,
): {
success: false;
status: ExpertFileAssignStatus;
message: string;
lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
} {
const assignee =
workflow?.assignedForReviewBy ?? workflow?.lockedBy;
const lockedAt = workflow?.lockedAt;
return {
success: false,
status: "locked",
message: "Request is already being answered by another expert",
lockedBy: {
actorId: assigneeId,
actorName: assignee?.actorName,
lockedAt: lockedAt
? new Date(lockedAt as Date).toISOString()
: undefined,
},
};
}
/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */
export function blameWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
if (workflow?.assignedForReviewBy) {
return undefined;
}
return workflow?.lockedBy;
}
export function claimWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
return blameWorkflowAssigneeToPersistOnLockExpiry(workflow);
}

View File

@@ -0,0 +1,46 @@
import { FilterQuery } from "mongoose";
import { UserModel } from "src/users/entities/schema/user.schema";
/** Canonical Iranian mobile: leading 0 + 10 digits (e.g. 09123456789). */
export function normalizeIranMobile(phone?: string): string | undefined {
if (!phone) return undefined;
const digits = String(phone).replace(/\D/g, "");
if (!digits) return undefined;
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
if (digits.startsWith("98") && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
if (digits.length === 11 && digits.startsWith("0")) return digits;
return digits;
}
/** Variants that may exist on legacy user rows (username/mobile). */
export function iranMobileLookupVariants(phone?: string): string[] {
const raw = phone?.trim();
const canonical = normalizeIranMobile(raw);
const variants = new Set<string>();
if (raw) variants.add(raw);
if (canonical) {
variants.add(canonical);
if (canonical.startsWith("0") && canonical.length === 11) {
variants.add(canonical.slice(1));
variants.add(`98${canonical.slice(1)}`);
variants.add(`0098${canonical.slice(1)}`);
}
}
return Array.from(variants).filter(Boolean);
}
export function buildUserLookupByPhone(
phone: string,
): FilterQuery<UserModel> {
const variants = iranMobileLookupVariants(phone);
if (variants.length === 0) {
return { username: phone };
}
return {
$or: variants.flatMap((v) => [{ username: v }, { mobile: v }]),
};
}

View File

@@ -301,6 +301,19 @@ export function normalizeDamageSelectedParts(
} }
if (typeof el === "object") { if (typeof el === "object") {
const o = el as Record<string, unknown>; const o = el as Record<string, unknown>;
const idOnly = toNum(o.id);
const hasName = typeof o.name === "string" && o.name.trim();
const hasKey = typeof o.key === "string" && String(o.key).trim();
if (idOnly != null && !hasName && !hasKey) {
let catItem: OuterPartCatalogItem | undefined;
if (catalog) catItem = catalog.find((c) => c.id === idOnly);
if (!catItem) catItem = CATALOG_ITEM_BY_ID.get(idOnly);
if (catItem) {
const list = catalog ?? outerCatalogListForItem(catItem);
out.push(catalogItemToSelectedPart(catItem, list));
continue;
}
}
if (typeof o.name === "string" && o.name.trim()) { if (typeof o.name === "string" && o.name.trim()) {
let name = o.name.trim(); let name = o.name.trim();
const inner = splitCatalogKeyToNameAndSide(name); const inner = splitCatalogKeyToNameAndSide(name);

View File

@@ -0,0 +1,43 @@
/** OTP expiry is stored as epoch milliseconds on user documents. */
export function otpExpireToEpochMs(value: unknown): number {
if (value == null) return 0;
if (typeof value === "number" && Number.isFinite(value)) {
if (value >= 1_000_000_000_000) return value;
if (value >= 1_000_000_000 && value < 1_000_000_000_000) {
return value * 1000;
}
return value;
}
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === "string") {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
export function isOtpExpiryActive(
otpExpire: unknown,
nowMs: number = Date.now(),
): boolean {
const exp = otpExpireToEpochMs(otpExpire);
return exp > nowMs;
}
export function computeOtpExpireMs(
expireMinutes: number,
nowMs: number = Date.now(),
): number {
const minutes =
Number.isFinite(expireMinutes) && expireMinutes > 0 ? expireMinutes : 2;
return nowMs + minutes * 60 * 1000;
}
export function readOtpExpireMinutesFromEnv(): number {
const raw = Number(process.env.EXP_OTP_TIME ?? "2");
return Number.isFinite(raw) && raw > 0 ? raw : 2;
}

View File

@@ -1,20 +0,0 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log("Before...");
const now = Date.now();
return next
.handle()
.pipe(tap(() => console.log(`After... ${Date.now() - now}ms`)));
}
}

View File

@@ -1,59 +1,93 @@
import { NestFactory } from "@nestjs/core"; import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import * as basicAuth from "express-basic-auth";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { ConfigService } from "@nestjs/config";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true, cors: true,
}); });
app.enableCors();
app.enableCors({ app.enableCors({
origin: "*", origin: "*",
methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS", methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS",
allowedHeaders: "Content-Type, Authorization", allowedHeaders: "Content-Type, Authorization",
}); });
app.use( const configService = app.get(ConfigService);
["/docs", "/docs-json", "/swagger"],
basicAuth({
challenge: true,
users: {
[process.env.SWAGGER_USER]: process.env.SWAGGER_PASSWORD,
},
}),
);
const config = new DocumentBuilder() const isDevelopment = configService.get<string>("NODE_ENV") === "development";
.setTitle("yara724-backend")
.setVersion("1.0.0") if (isDevelopment) {
.addServer(process.env.URL) app.use("/docs", (req, res, next) => {
.addServer(process.env.BASE_URL + "/api") const authHeader = req.headers.authorization;
.addServer("http://192.168.20.170:9001")
.addServer("http://localhost:9001") const challenge = () => {
.addBearerAuth() res.setHeader("WWW-Authenticate", 'Basic realm="Swagger"');
.build(); res.status(401).send("Unauthorized");
const docs = SwaggerModule.createDocument(app, config); };
SwaggerModule.setup("/docs", app, docs, {
swaggerOptions: { if (!authHeader?.startsWith("Basic ")) {
persistAuthorization: true, return challenge();
docExpansion: "none", }
tagsSorter: (a: string, b: string) => {
const priority: Record<string, number> = { try {
user: 0, const base64 = authHeader.split(" ")[1];
actor: 1, const [username, ...rest] = Buffer.from(base64, "base64")
}; .toString("utf8")
const pa = priority[a] ?? 100; .split(":");
const pb = priority[b] ?? 100; const password = rest.join(":");
if (pa !== pb) return pa - pb;
return a.localeCompare(b); const expectedUser = configService.get<string>("SWAGGER_USER_DEV", "");
const expectedPassword = configService.get<string>(
"SWAGGER_PASSWORD_DEV",
"",
);
if (username !== expectedUser || password !== expectedPassword) {
return challenge();
}
next();
} catch {
return challenge();
}
});
const config = new DocumentBuilder()
.setTitle("yara724-backend")
.setVersion("1.0.0")
.addServer(process.env.BASE_URL_DEV + "/api")
.addServer("http://192.168.20.170:9001")
.addServer("https://user.yara724.com/api")
.addServer("http://localhost:9001")
.addBearerAuth()
.build();
const docs = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, docs, {
swaggerOptions: {
persistAuthorization: true,
docExpansion: "none",
ui: isDevelopment ? true : false,
raw: isDevelopment ? true : false,
tagsSorter: (a: string, b: string) => {
const priority: Record<string, number> = {
user: 0,
actor: 1,
};
const pa = priority[a] ?? 100;
const pb = priority[b] ?? 100;
if (pa !== pb) return pa - pb;
return a.localeCompare(b);
},
}, },
}, });
}); }
await app.listen(process.env.PORT); await app.listen(process.env.PORT);
} }
bootstrap(); void bootstrap();

View File

@@ -8,6 +8,7 @@ import {
LightCondition, LightCondition,
} from "src/Types&Enums/blame-request-management/accident-conditions.enum"; } from "src/Types&Enums/blame-request-management/accident-conditions.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { IsEnum } from "class-validator";
export class InitialFormDto { export class InitialFormDto {
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: false })
@@ -161,16 +162,17 @@ export class LocationDto {
lon: number; lon: number;
} }
export class CreateRequestDto { // export class CreateRequestDto {
@ApiProperty({ type: String, required: false }) // @ApiProperty({ type: String, required: false })
type?: "THIRD_PARTY" | "CAR_BODY"; // type?: "THIRD_PARTY" | "CAR_BODY";
} // }
/** /**
* V2 create blame request (new model `BlameRequest`). * V2 create blame request (new model `BlameRequest`).
*/ */
export class CreateBlameRequestDtoV2 { export class CreateBlameRequestDtoV2 {
@ApiProperty({ enum: BlameRequestType }) @IsEnum(BlameRequestType)
@ApiProperty({ enum: BlameRequestType, description: "Type of blame request" })
type: BlameRequestType; type: BlameRequestType;
} }

View File

@@ -100,3 +100,4 @@ export class BlameRequest {
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);
BlameRequestSchema.index({ status: 1, "workflow.locked": 1 });

View File

@@ -48,5 +48,12 @@ export class Workflow {
@Prop({ type: ActorLockSchema }) @Prop({ type: ActorLockSchema })
lockedBy?: ActorLock; lockedBy?: ActorLock;
/**
* First expert who called review assign; kept after the 15m workflow lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ActorLockSchema })
assignedForReviewBy?: ActorLock;
} }
export const WorkflowSchema = SchemaFactory.createForClass(Workflow); export const WorkflowSchema = SchemaFactory.createForClass(Workflow);

View File

@@ -27,6 +27,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto"; import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto";
@@ -112,33 +113,33 @@ export class ExpertInitiatedController {
); );
} }
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Post("create") // @Post("create")
@ApiOperation({ // @ApiOperation({
summary: "Create expert-initiated blame file", // summary: "Create expert-initiated blame file",
description: // description:
"Expert creates an initial incomplete blame file. Can be IN_PERSON (expert fills all info) or LINK (expert sends link to users).", // "Expert creates an initial incomplete blame file. Can be IN_PERSON (expert fills all info) or LINK (expert sends link to users).",
}) // })
@ApiBody({ type: CreateExpertInitiatedFileDto }) // @ApiBody({ type: CreateExpertInitiatedFileDto })
@ApiResponse({ // @ApiResponse({
status: 201, // status: 201,
description: "File created successfully", // description: "File created successfully",
schema: { // schema: {
type: "object", // type: "object",
properties: { // properties: {
requestId: { type: "string" }, // requestId: { type: "string" },
}, // },
}, // },
}) // })
async createExpertInitiatedFile( // async createExpertInitiatedFile(
@CurrentUser() expert: any, // @CurrentUser() expert: any,
@Body() dto: CreateExpertInitiatedFileDto, // @Body() dto: CreateExpertInitiatedFileDto,
) { // ) {
return await this.requestManagementService.createExpertInitiatedFile( // return await this.requestManagementService.createExpertInitiatedFile(
expert, // expert,
dto, // dto,
); // );
} // }
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Post("complete-form-third-party/:requestId") @Post("complete-form-third-party/:requestId")
@@ -228,7 +229,7 @@ export class ExpertInitiatedController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 20 * 1024 * 1024, // 20MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/video", destination: "./files/video",
@@ -285,7 +286,7 @@ export class ExpertInitiatedController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("voice", { FileInterceptor("voice", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/voice", destination: "./files/voice",

View File

@@ -25,6 +25,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
@@ -378,7 +379,7 @@ export class ExpertInitiatedV2Controller {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/video", destination: "./files/video",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -417,7 +418,7 @@ export class ExpertInitiatedV2Controller {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("voice", { FileInterceptor("voice", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/voice", destination: "./files/voice",
filename: (req, file, callback) => { filename: (req, file, callback) => {
@@ -483,7 +484,7 @@ export class ExpertInitiatedV2Controller {
}) })
@UseInterceptors( @UseInterceptors(
FileInterceptor("sign", { FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/signs", destination: "./files/signs",
filename: (req, file, callback) => { filename: (req, file, callback) => {

View File

@@ -8,6 +8,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto"; import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto"; import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
@@ -75,7 +76,7 @@ export class RegistrarInitiatedController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/video", destination: "./files/video",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
@@ -94,7 +95,7 @@ export class RegistrarInitiatedController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("voice", { FileInterceptor("voice", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/voice", destination: "./files/voice",
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
@@ -127,7 +128,7 @@ export class RegistrarInitiatedController {
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@UseInterceptors( @UseInterceptors(
FileInterceptor("sign", { FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/signs", destination: "./files/signs",
filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`), filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`),

View File

@@ -32,11 +32,11 @@ import { CurrentUser } from "src/decorators/user.decorator";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { BlameDocumentType } from "./entities/schema/blame-document.schema"; import { BlameDocumentType } from "./entities/schema/blame-document.schema";
import { import {
CarBodyFormDto, CarBodyFormDto,
CarBodySecondForm, CarBodySecondForm,
CreateRequestDto,
DescriptionDto, DescriptionDto,
InitialFormDto, InitialFormDto,
LocationDto, LocationDto,
@@ -53,18 +53,18 @@ export class RequestManagementController {
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
) {} ) {}
@ApiOperation({ deprecated: true }) // @ApiOperation({ deprecated: true })
@Post() // @Post()
@UseGuards(GlobalGuard) // @UseGuards(GlobalGuard)
async createRequest( // async createRequest(
@CurrentUser() user, // @CurrentUser() user,
@Body() createRequestDto: CreateRequestDto, // @Body() createRequestDto: CreateRequestDto,
) { // ) {
return await this.requestManagementService.createRequest( // return await this.requestManagementService.createRequest(
user, // user,
createRequestDto.type, // createRequestDto.type,
); // );
} // }
@ApiOperation({ deprecated: true }) @ApiOperation({ deprecated: true })
@Post("/initial-form/:requestId") @Post("/initial-form/:requestId")
@@ -99,7 +99,7 @@ export class RequestManagementController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 20 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/video", destination: "./files/video",
@@ -217,7 +217,7 @@ export class RequestManagementController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/voice", destination: "./files/voice",
@@ -326,7 +326,7 @@ export class RequestManagementController {
@UseInterceptors( @UseInterceptors(
FileInterceptor("sign", { FileInterceptor("sign", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/signs", destination: "./files/signs",
@@ -381,7 +381,7 @@ export class RequestManagementController {
{ name: "voice", maxCount: 1 }, { name: "voice", maxCount: 1 },
], ],
{ {
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({ storage: diskStorage({
destination: "./files/blame-resend-docs", destination: "./files/blame-resend-docs",
filename: (req, file, callback) => { filename: (req, file, callback) => {

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,10 @@ import {
ApiParam, ApiParam,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { FileFieldsInterceptor, FileInterceptor } from "@nestjs/platform-express"; import {
FileFieldsInterceptor,
FileInterceptor,
} from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { Request } from "express"; import { Request } from "express";
import { GlobalGuard } from "src/auth/guards/global.guard"; import { GlobalGuard } from "src/auth/guards/global.guard";
@@ -30,6 +33,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { MediaPolicyService } from "src/media-policy/media-policy.service"; import { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { import {
@@ -58,7 +62,7 @@ export class RequestManagementV2Controller {
constructor( constructor(
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
private readonly mediaPolicyService: MediaPolicyService, private readonly mediaPolicyService: MediaPolicyService,
) { } ) {}
@Post() @Post()
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
@@ -113,7 +117,11 @@ export class RequestManagementV2Controller {
@Body() body: BlameConfessionDtoV2, @Body() body: BlameConfessionDtoV2,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return this.requestManagementService.blameConfessionV2(requestId, body, user); return this.requestManagementService.blameConfessionV2(
requestId,
body,
user,
);
} }
/** CAR_BODY only: submit accident type (car vs object). Call this when nextStep is CAR_BODY_ACCIDENT_TYPE. */ /** CAR_BODY only: submit accident type (car vs object). Call this when nextStep is CAR_BODY_ACCIDENT_TYPE. */
@@ -126,7 +134,11 @@ export class RequestManagementV2Controller {
@Body() body: CarBodyFormDto, @Body() body: CarBodyFormDto,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return this.requestManagementService.carBodyAccidentTypeFormV2(requestId, body, user); return this.requestManagementService.carBodyAccidentTypeFormV2(
requestId,
body,
user,
);
} }
@ApiBody({ @ApiBody({
@@ -144,7 +156,7 @@ export class RequestManagementV2Controller {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 20 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/video", destination: "./files/video",
@@ -194,7 +206,11 @@ export class RequestManagementV2Controller {
@Body() body: LocationDto, @Body() body: LocationDto,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return this.requestManagementService.addDetailLocationV2(requestId, body, user); return this.requestManagementService.addDetailLocationV2(
requestId,
body,
user,
);
} }
@ApiBody({ @ApiBody({
@@ -212,7 +228,7 @@ export class RequestManagementV2Controller {
@UseInterceptors( @UseInterceptors(
FileInterceptor("file", { FileInterceptor("file", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/voice", destination: "./files/voice",
@@ -268,7 +284,11 @@ export class RequestManagementV2Controller {
@Body() body: DescriptionDto, @Body() body: DescriptionDto,
@CurrentUser() user, @CurrentUser() user,
) { ) {
return this.requestManagementService.addDescriptionV2(requestId, body, user); return this.requestManagementService.addDescriptionV2(
requestId,
body,
user,
);
} }
@Post("add-second-party/:phoneNumber/:requestId/:frontendRoute") @Post("add-second-party/:phoneNumber/:requestId/:frontendRoute")
@@ -332,7 +352,7 @@ export class RequestManagementV2Controller {
@UseInterceptors( @UseInterceptors(
FileInterceptor("sign", { FileInterceptor("sign", {
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB fileSize: DEFAULT_MEDIA_MAX_BYTES,
}, },
storage: diskStorage({ storage: diskStorage({
destination: "./files/signs", destination: "./files/signs",
@@ -354,9 +374,8 @@ export class RequestManagementV2Controller {
await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
// Convert string "true"/"false" to boolean // Convert string "true"/"false" to boolean
const acceptDecision = typeof isAccept === "string" const acceptDecision =
? isAccept === "true" typeof isAccept === "string" ? isAccept === "true" : Boolean(isAccept);
: Boolean(isAccept);
return this.requestManagementService.userSignDecisionV2( return this.requestManagementService.userSignDecisionV2(
requestId, requestId,
@@ -404,17 +423,15 @@ export class RequestManagementV2Controller {
format: "binary", format: "binary",
description: "Voice recording file (if requested)", description: "Voice recording file (if requested)",
}, },
video: { carPlate: {
type: "string", type: "string",
format: "binary", description: "car plate",
description: "Video file (if requested)", },
chassisNumber: {
type: "string",
description: "chassis number شماره شاسی",
}, },
description: { description: {
type: "string",
description:
"Text description when expert requested ResendItemType.description",
},
textDescription: {
type: "string", type: "string",
description: description:
"Alias for `description` (same semantics); either field may be used.", "Alias for `description` (same semantics); either field may be used.",
@@ -430,7 +447,8 @@ export class RequestManagementV2Controller {
{ name: "nationalCertificate", maxCount: 1 }, { name: "nationalCertificate", maxCount: 1 },
{ name: "carGreenCard", maxCount: 1 }, { name: "carGreenCard", maxCount: 1 },
{ name: "voice", maxCount: 1 }, { name: "voice", maxCount: 1 },
{ name: "video", maxCount: 1 }, { name: "carPlate", maxCount: 1 },
{ name: "chassisNumber", maxCount: 1 },
], ],
{ {
storage: diskStorage({ storage: diskStorage({
@@ -459,7 +477,8 @@ export class RequestManagementV2Controller {
nationalCertificate?: Express.Multer.File[]; nationalCertificate?: Express.Multer.File[];
carGreenCard?: Express.Multer.File[]; carGreenCard?: Express.Multer.File[];
voice?: Express.Multer.File[]; voice?: Express.Multer.File[];
video?: Express.Multer.File[]; carPlate?: Express.Multer.File[];
chassisNumber?: Express.Multer.File[];
}, },
@Body("description") description?: string, @Body("description") description?: string,
@Body("textDescription") textDescription?: string, @Body("textDescription") textDescription?: string,
@@ -473,7 +492,8 @@ export class RequestManagementV2Controller {
nationalCertificate: "image", nationalCertificate: "image",
carGreenCard: "image", carGreenCard: "image",
voice: "voice", voice: "voice",
video: "video", carPlate: "carPlate",
chassisNumber: "chassisNumber",
}); });
const descCandidates = [description, textDescription] const descCandidates = [description, textDescription]
@@ -489,4 +509,3 @@ export class RequestManagementV2Controller {
); );
} }
} }

View File

@@ -9,12 +9,12 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import axios from "axios";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema"; import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
import { SystemSettingsService } from "src/system-settings/system-settings.service"; import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { SandHubDetailDto } from "./dto/sand-hub.dto"; import { SandHubDetailDto } from "./dto/sand-hub.dto";
import { jalaliToGregorianDate } from "src/helpers/date-jalali"; import { jalaliToGregorianDate } from "src/helpers/date-jalali";
import { firstValueFrom } from "rxjs";
@Injectable() @Injectable()
export class SandHubService { export class SandHubService {
@@ -123,8 +123,7 @@ export class SandHubService {
const u = (url || "").toLowerCase(); const u = (url || "").toLowerCase();
if (u.includes("personal-inquiry")) { if (u.includes("personal-inquiry")) {
const p = payload as { nationalCode?: string } | undefined; const p = payload as { nationalCode?: string } | undefined;
const nin = const nin = typeof p?.nationalCode === "string" ? p.nationalCode : "-";
typeof p?.nationalCode === "string" ? p.nationalCode : "-";
return { return {
message: "success", message: "success",
data: { data: {
@@ -182,12 +181,11 @@ export class SandHubService {
); );
try { try {
const response = await this.httpService.axiosRef.post( const response = await firstValueFrom(
process.env.SANDHUB_URL_LOGIN, this.httpService.post(process.env.SANHUB_URL_LOGIN, {
{ email: process.env.SANHUB_USERNAME,
email: process.env.SANDHUB_USERNAME, password: process.env.SANHUB_PASSWORD,
password: process.env.SANDHUB_PASSWORD, }),
},
); );
const token = response.data.accessToken; const token = response.data.accessToken;
@@ -237,16 +235,18 @@ export class SandHubService {
); );
try { try {
const response = await this.httpService.axiosRef.post( const response = await firstValueFrom(
`${baseUrl}/user/login`, this.httpService.post(
{ email, password }, `${baseUrl}/user/login`,
{ { email, password },
headers: { {
Accept: "*/*", headers: {
"Content-Type": "application/json", Accept: "*/*",
"Content-Type": "application/json",
},
timeout: 30000,
}, },
timeout: 30000, ),
},
); );
const token = response.data?.accessToken; const token = response.data?.accessToken;
@@ -260,7 +260,10 @@ export class SandHubService {
return this.tejaratAccessToken; return this.tejaratAccessToken;
} catch (er) { } catch (er) {
this.logger.error("Failed to login to Tejarat inquiry:", er?.message || er); this.logger.error(
"Failed to login to Tejarat inquiry:",
er?.message || er,
);
this.tejaratAccessToken = null; this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null; this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed"); throw new UnauthorizedException("Tejarat inquiry authentication failed");
@@ -278,14 +281,16 @@ export class SandHubService {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
const token = await this.getTejaratAccessToken(); const token = await this.getTejaratAccessToken();
try { try {
const response = await axios.post(url, payload, { const response = await firstValueFrom(
headers: { this.httpService.post(url, payload, {
Authorization: `Bearer ${token}`, headers: {
"Content-Type": "application/json", Authorization: `Bearer ${token}`,
Accept: "application/json", "Content-Type": "application/json",
}, Accept: "application/json",
timeout: 30000, },
}); timeout: 30000,
}),
);
if (!response?.data) throw new Error("EMPTY_RESPONSE"); if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data; return response.data;
} catch (err: any) { } catch (err: any) {
@@ -350,6 +355,58 @@ export class SandHubService {
return await this.sandHubDbService.findOneBySandHubId(sandHubId); return await this.sandHubDbService.findOneBySandHubId(sandHubId);
} }
/**
* Tejarat block inquiry for Car Body Insurance (Badane).
* Returns both raw API response and mapped old-format response structure.
*/
async getTejaratCarBodyInquiry(userDetail: SandHubDetailDto): Promise<{
raw: any;
}> {
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
// 1. Build payload following the strict data requirements of the external API
const requestPayload = {
part1: Number(userDetail.plate.leftDigits),
part2: String(userDetail.plate.centerAlphabet),
part3: Number(userDetail.plate.centerDigits),
part4: Number(userDetail.plate.ir),
nationalCode: String(userDetail.nationalCodeOfInsurer),
};
const requestUrl = `${baseUrl}/block-inquiry-tejarat/badane`;
const live = await this.useLiveSandHubApis();
let raw: any;
if (live) {
// 2. Safely obtain a fresh or cached access token using your helper
const token = await this.getTejaratAccessToken();
try {
// 3. Make the live call using NestJS HttpService + firstValueFrom pattern
const response = await firstValueFrom(
this.httpService.post(requestUrl, requestPayload, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
timeout: 30000,
}),
);
raw = response.data;
} catch (error) {
this.logger.error(
`[TEJARAT BADANE ERROR] Request failed for ${requestUrl}: ${error?.message || error}`,
error?.stack,
);
throw error;
}
}
return { raw };
}
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) { private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
if (!(await this.useLiveSandHubApis())) { if (!(await this.useLiveSandHubApis())) {
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`); this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
@@ -361,14 +418,16 @@ export class SandHubService {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
try { try {
const response = await axios.post(url, payload, { const response = await firstValueFrom(
headers: { this.httpService.post(url, payload, {
Authorization: `Bearer ${token}`, headers: {
"Content-Type": "application/json", Authorization: `Bearer ${token}`,
Accept: "application/json", "Content-Type": "application/json",
}, Accept: "application/json",
timeout: 50000, },
}); timeout: 50000,
}),
);
if (!response?.data) throw new Error("EMPTY_RESPONSE"); if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data; return response.data;
} catch (err) { } catch (err) {
@@ -397,22 +456,32 @@ export class SandHubService {
// Vehicle information // Vehicle information
MapTypNam: newResponse.vehiclePersianName || newResponse.MapTypNam, MapTypNam: newResponse.vehiclePersianName || newResponse.MapTypNam,
UsageField: newResponse.persianCarType || newResponse.UsageField || (newResponse.usgCod === "8" ? "شخصی" : "نامشخص"), UsageField:
newResponse.persianCarType ||
newResponse.UsageField ||
(newResponse.usgCod === "8" ? "شخصی" : "نامشخص"),
MapUsageName: newResponse.MapUsageName || newResponse.MapUsageName, MapUsageName: newResponse.MapUsageName || newResponse.MapUsageName,
// Financial coverage // Financial coverage
FinancialCvrCptl: newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0", FinancialCvrCptl:
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
// Dates // Dates
IssueDate: newResponse.persianStartDate || newResponse.IssueDate, IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
EndDate: newResponse.persianEndDate || newResponse.EndDate, EndDate: newResponse.persianEndDate || newResponse.EndDate,
// Insurance details // Insurance details
LastCompanyDocumentNumber: newResponse.lastCompanyInsuranceNumber || newResponse.LastCompanyDocumentNumber || newResponse.insuranceNumber, LastCompanyDocumentNumber:
newResponse.lastCompanyInsuranceNumber ||
newResponse.LastCompanyDocumentNumber ||
newResponse.insuranceNumber,
// Technical details // Technical details
MtrNum: newResponse.MtrNum || newResponse.mtrnum, MtrNum: newResponse.MtrNum || newResponse.mtrnum,
ShsNum: newResponse.ShsNum || newResponse.shsNam || newResponse.ChassisNumberField, ShsNum:
newResponse.ShsNum ||
newResponse.shsNam ||
newResponse.ChassisNumberField,
vin: newResponse.vin || newResponse.VinNumberField || newResponse.vin, vin: newResponse.vin || newResponse.VinNumberField || newResponse.vin,
VinNumberField: newResponse.vin || newResponse.VinNumberField, VinNumberField: newResponse.vin || newResponse.VinNumberField,
ChassisNumberField: newResponse.ChassisNumberField || newResponse.shsNam, ChassisNumberField: newResponse.ChassisNumberField || newResponse.shsNam,
@@ -422,7 +491,8 @@ export class SandHubService {
// Discount information // Discount information
DisFnYrPrcnt: newResponse.DisFnYrPrcnt || newResponse.disFnYrPrcnt || "0", DisFnYrPrcnt: newResponse.DisFnYrPrcnt || newResponse.disFnYrPrcnt || "0",
DisLfYrPrcnt: newResponse.DisLfYrPrcnt || newResponse.disLfYrPrcnt || "0", DisLfYrPrcnt: newResponse.DisLfYrPrcnt || newResponse.disLfYrPrcnt || "0",
DisPrsnYrPrcnt: newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0", DisPrsnYrPrcnt:
newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0",
DisFnYrNum: newResponse.DisFnYrNum || newResponse.disFnYrNum, DisFnYrNum: newResponse.DisFnYrNum || newResponse.disFnYrNum,
DisLfYrNum: newResponse.DisLfYrNum || newResponse.disLfYrNum, DisLfYrNum: newResponse.DisLfYrNum || newResponse.disLfYrNum,
DisPrsnYrNum: newResponse.DisPrsnYrNum || newResponse.disPrsnYrNum, DisPrsnYrNum: newResponse.DisPrsnYrNum || newResponse.disPrsnYrNum,
@@ -433,10 +503,13 @@ export class SandHubService {
CarGroupCode: newResponse.CarGroupCode || newResponse.carGrpCod, CarGroupCode: newResponse.CarGroupCode || newResponse.carGrpCod,
// Policy information // Policy information
ThirdPolicyCode: newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode, ThirdPolicyCode:
newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode,
// Endorsement data // Endorsement data
EdrsJson: newResponse.EdrsJson || (newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined), EdrsJson:
newResponse.EdrsJson ||
(newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined),
// Insurance fullname // Insurance fullname
InsuranceFullName: newResponse.InsuranceFullName || newResponse.fullname, InsuranceFullName: newResponse.InsuranceFullName || newResponse.fullname,
@@ -455,7 +528,7 @@ export class SandHubService {
rightTwoDigits: String(userDetail.plate.ir), rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer, nationalCode: userDetail.nationalCodeOfInsurer,
}; };
const base = process.env.SANDHUB_BASE_URL ?? ""; const base = process.env.SANHUB_BASE_URL ?? "";
const requestUrl = `${base}/block-inquiry-tejarat`; const requestUrl = `${base}/block-inquiry-tejarat`;
let response: any; let response: any;
@@ -486,12 +559,9 @@ export class SandHubService {
* this codebase has the value as a Jalali date (number `13770624` or string * this codebase has the value as a Jalali date (number `13770624` or string
* `"1377-06-24"`/`"1377/06/24"`). We convert here so callers don't have to. * `"1377-06-24"`/`"1377/06/24"`). We convert here so callers don't have to.
*/ */
async getPersonalInquiry( async getPersonalInquiry(nationalCode: string, birthDate: number | string) {
nationalCode: string,
birthDate: number | string,
) {
try { try {
const requestUrl = `${process.env.SANDHUB_BASE_URL}/personal-inquiry/tejarat-no`; const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`;
const gregorianBirthdate = jalaliToGregorianDate(birthDate); const gregorianBirthdate = jalaliToGregorianDate(birthDate);
if (!gregorianBirthdate) { if (!gregorianBirthdate) {
@@ -531,7 +601,7 @@ export class SandHubService {
nationalCode: string, nationalCode: string,
driverLicenseNumber: string, driverLicenseNumber: string,
) { ) {
const requestUrl = `${process.env.SANDHUB_BASE_URL}/driver-license-check`; const requestUrl = `${process.env.SANHUB_BASE_URL}/driver-license-check`;
const requestPayload = { const requestPayload = {
driverLicenseNumber, driverLicenseNumber,
nationalCode, nationalCode,
@@ -570,7 +640,7 @@ export class SandHubService {
async getCarOwnershipInfo(plate: any, nationalCode: string) { async getCarOwnershipInfo(plate: any, nationalCode: string) {
try { try {
const requestUrl = `${process.env.SANDHUB_BASE_URL}/ownership`; const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
const requestPayload = { const requestPayload = {
Plk1: String(plate.leftDigits), Plk1: String(plate.leftDigits),
Plk2: String(plate.centerAlphabet), Plk2: String(plate.centerAlphabet),
@@ -606,7 +676,7 @@ export class SandHubService {
async getShebaValidation(nationalId: string, shebaId: string) { async getShebaValidation(nationalId: string, shebaId: string) {
try { try {
const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba/sheba-tejaratno`; const requestUrl = `${process.env.SANHUB_BASE_URL}/sheba/sheba-tejaratno`;
const requestPayload = { const requestPayload = {
AccountOwnerType: "1", AccountOwnerType: "1",
NationalId: nationalId, NationalId: nationalId,

View File

@@ -0,0 +1,102 @@
import { Injectable, Logger } from "@nestjs/common";
import { KavenegarService } from "./kavenegar.service";
import {
isKavenegarSuccess,
KavenegarNormalized,
normalizeKavenegarBody,
safeJsonStringify,
unwrapKavenegarTransportError,
} from "./kavenegar-response";
import {
SmsProviderException,
SmsTransportException,
} from "./sms-provider.exception";
import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
@Injectable()
export class KavenegarSmsGateway {
private readonly logger = new Logger(KavenegarSmsGateway.name);
constructor(private readonly sender: KavenegarService) {}
async sendMessage(data: SendMessage) {
try {
const body = await this.sender.send({
sender: "10008663",
...data,
});
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection("send", data.receptor, undefined, normalized);
throw new SmsProviderException("send", {
receptor: data.receptor,
providerBody: body,
normalized,
});
}
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
throw new SmsTransportException("send", { receptor: data.receptor }, e);
}
}
async verifyLookUp(data: VerifyLookUpMessage) {
try {
const body = await this.sender.verifyLookup(data);
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection(
"verifyLookUp",
data.receptor,
data.template,
normalized,
);
throw new SmsProviderException("verifyLookup", {
receptor: data.receptor,
template: data.template,
providerBody: body,
normalized,
});
}
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
this.logger.error(`Kavenegar transport error ${detail}`);
throw new SmsTransportException(
"verifyLookup",
{
receptor: data.receptor,
template: data.template,
},
e,
);
}
}
private logProviderRejection(
op: "send" | "verifyLookUp",
receptor: string,
template: string | undefined,
normalized: KavenegarNormalized,
) {
const t = template != null ? ` template=${template}` : "";
this.logger.warn(
`Kavenegar ${op} rejected receptor=${receptor}${t} providerStatus=${normalized.httpLikeStatus}`,
);
}
}

View File

@@ -0,0 +1,45 @@
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
@Injectable()
export class KavenegarService {
private readonly apiKey: string;
private readonly baseUrl: string;
constructor(
private readonly http: HttpService,
private readonly configService: ConfigService,
) {
this.apiKey = this.configService.get<string>("SMS_API_KEY", "");
this.baseUrl = `https://api.kavenegar.com/v1/${this.apiKey}`;
}
async send(data: { receptor: string; message: string; sender?: string }) {
const response = await firstValueFrom(
this.http.post(`${this.baseUrl}/sms/send.json`, null, {
params: data,
}),
);
return response.data;
}
async verifyLookup(data: {
receptor: string;
token: string;
template: string;
token2?: string;
token3?: string;
}) {
const response = await firstValueFrom(
this.http.get(`${this.baseUrl}/verify/lookup.json`, {
params: data,
}),
);
return response.data;
}
}

View File

@@ -0,0 +1,107 @@
import { Injectable, Logger } from "@nestjs/common";
import { isAxiosError } from "axios";
import { buildParsianTemplateMessage } from "./parsian-template-messages";
import {
SmsProviderException,
SmsTransportException,
} from "./sms-provider.exception";
import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
import { firstValueFrom } from "rxjs";
import { HttpService } from "@nestjs/axios";
@Injectable()
export class ParsianSmsGateway {
private readonly logger = new Logger(ParsianSmsGateway.name);
constructor(private readonly httpService: HttpService) {}
async sendMessage(data: SendMessage) {
try {
const body = await this.requestSend(data.receptor, data.message);
this.logger.log(`Parsian Send ok receptor=${data.receptor}`);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = isAxiosError(e)
? {
status: e.response?.status,
data: e.response?.data,
message: e.message,
}
: e;
this.logger.error(
`Parsian Send transport error receptor=${data.receptor} detail=${JSON.stringify(detail)}`,
);
throw new SmsTransportException("send", { receptor: data.receptor }, e);
}
}
async verifyLookUp(data: VerifyLookUpMessage) {
const message = buildParsianTemplateMessage(data);
try {
const body = await this.requestSend(data.receptor, message);
this.logger.log(
`Parsian verifyLookUp ok receptor=${data.receptor} template=${data.template}`,
);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = isAxiosError(e)
? {
status: e.response?.status,
data: e.response?.data,
message: e.message,
}
: e;
this.logger.error(
`Parsian verifyLookUp transport error receptor=${data.receptor} template=${data.template} detail=${JSON.stringify(detail)}`,
);
throw new SmsTransportException(
"verifyLookup",
{ receptor: data.receptor, template: data.template },
e,
);
}
}
private parsianHeaders() {
return {
"Content-Type": "application/json",
"X-PACKAGE-API-KEY": process.env.PARSIAN_API_KEY ?? "",
Authorization: `Basic ${process.env.PARSIAN_BASIC_TOKEN ?? ""}`,
};
}
private async requestSend(
receptor: string,
message: string,
): Promise<unknown> {
const baseUrl = process.env.PARSIAN_SMS_URL;
if (!baseUrl?.trim()) {
throw new SmsTransportException(
"send",
{ receptor },
new Error("PARSIAN_SMS_URL is not configured"),
);
}
const url = `${baseUrl}=${receptor}&Message=${encodeURIComponent(message)}`;
const response = await firstValueFrom(
this.httpService.get(url, { headers: this.parsianHeaders() }),
);
if (response.status < 200 || response.status >= 300) {
throw new SmsProviderException("send", {
receptor,
providerBody: response.data,
normalized: {
httpLikeStatus: response.status,
message: String(response.statusText ?? ""),
raw: JSON.stringify(response.data),
},
});
}
return response.data;
}
}

View File

@@ -0,0 +1,65 @@
import { VerifyLookUpMessage } from "./sms-gateway.types";
const TOKEN_KEYS = ["token", "token2", "token3", "token10"] as const;
/** Kavenegar template name → plain-text body for Parsian (placeholders: {token}, {token2}, …). */
const PARSIAN_TEMPLATE_BODIES: Record<string, string> = {
"yara724-invite-link":
"کاربر محترم، لینک ورود به پرونده یارا724:\nشناسه: {token}\n{token2}",
"yara-field-expert-link":
"کارشناس میدانی بیمه {token} ({token2}) پرونده شما را بررسی می‌کند.\nلینک: {token3}",
"yara-blame-agreement":
"پرونده تصادف شما ({token}) آماده امضاست.\nلینک: {token2}",
"yara-claim-link":
"پرونده خسارت شما ({token}) آماده ثبت است.\nلینک: {token2}",
"yara-expert-lock":
"کارشناس ({token3}) پرونده {token} ({token2}) را در حال بررسی دارد.",
"yara-resend-documents":
"لطفاً مدارک پرونده {token} ({token2}) را مجدداً ارسال کنید.\nلینک: {token3}",
"yara-signature":
"امضای پرونده {token} ({token2}) توسط کارشناس {token3} بررسی شد.\nلینک: {token10}",
};
function applyTokens(
pattern: string,
data: Pick<
VerifyLookUpMessage,
"token" | "token2" | "token3" | "token10"
>,
): string {
let out = pattern;
for (const key of TOKEN_KEYS) {
const value = data[key];
if (value != null && value !== "") {
out = out.split(`{${key}}`).join(value);
}
}
return out.replace(/\{token\d*\}/g, "").trim();
}
export function buildParsianTemplateMessage(
data: VerifyLookUpMessage,
): string {
const authTemplate = process.env.AUTH_SMS_TEMPLATE?.trim();
if (authTemplate && data.template === authTemplate) {
const authMessage =
process.env.AUTH_SMS_MESSAGE?.trim() ??
"کاربر محترم رمز یکبار مصرف شما:\nCode: {token}";
return applyTokens(authMessage, data);
}
const body = PARSIAN_TEMPLATE_BODIES[data.template];
if (body) {
return applyTokens(body, data);
}
const parts = [
`پیام سیستم (${data.template})`,
data.token ? `کد: ${data.token}` : "",
data.token2 ?? "",
data.token3 ?? "",
data.token10 ?? "",
].filter(Boolean);
return parts.join("\n");
}

View File

@@ -1,18 +1,20 @@
import { KavenegarModule } from "@fraybabak/kavenegar_nest"; import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import * as dotenv from "dotenv"; import { ConfigModule } from "@nestjs/config";
import { KavenegarService } from "./kavenegar.service";
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
import { ParsianSmsGateway } from "./parsian-sms.gateway";
import { SmsGatewayService } from "./sms-gateway.service"; import { SmsGatewayService } from "./sms-gateway.service";
dotenv.config();
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@Module({ @Module({
imports: [ imports: [HttpModule, ConfigModule],
KavenegarModule.forRoot({ providers: [
apikey: process.env.SMS_API_KEY || "", KavenegarService,
}), KavenegarSmsGateway,
ParsianSmsGateway,
SmsGatewayService,
], ],
providers: [SmsGatewayService],
exports: [SmsGatewayService], exports: [SmsGatewayService],
}) })
export class SmsGatewayModule {} export class SmsGatewayModule {}

View File

@@ -1,112 +1,33 @@
import { KavenegarService } from "@fraybabak/kavenegar_nest"; import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common"; import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
import { import { ParsianSmsGateway } from "./parsian-sms.gateway";
isKavenegarSuccess, import { getSmsProvider } from "./sms-provider.config";
KavenegarNormalized, import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
normalizeKavenegarBody,
safeJsonStringify,
unwrapKavenegarTransportError,
} from "./kavenegar-response";
import {
SmsProviderException,
SmsTransportException,
} from "./sms-provider.exception";
export interface SendMessage { export type { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
message: string;
receptor: string;
}
export interface VerifyLookUpMessage {
template: string;
token: string;
token2?: string;
token3?: string;
token10?: string;
receptor: string;
}
@Injectable() @Injectable()
export class SmsGatewayService { export class SmsGatewayService implements OnModuleInit {
private readonly logger = new Logger(SmsGatewayService.name); private readonly logger = new Logger(SmsGatewayService.name);
constructor(private readonly sender: KavenegarService) {} constructor(
private readonly kavenegar: KavenegarSmsGateway,
private readonly parsian: ParsianSmsGateway,
) {}
onModuleInit() {
this.logger.log(`SMS provider active: ${getSmsProvider()}`);
}
async sendMessage(data: SendMessage) { async sendMessage(data: SendMessage) {
try { return this.activeGateway().sendMessage(data);
const body = await this.sender.Send({
sender: "10008663",
...data,
});
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection("send", data.receptor, undefined, normalized);
throw new SmsProviderException("send", {
receptor: data.receptor,
providerBody: body,
normalized,
});
}
this.logger.log(
`Kavenegar Send ok receptor=${data.receptor} status=200`,
);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
this.logger.error(
`Kavenegar Send transport error receptor=${data.receptor} detail=${detail}`,
);
throw new SmsTransportException("send", { receptor: data.receptor }, e);
}
} }
async verifyLookUp(data: VerifyLookUpMessage) { async verifyLookUp(data: VerifyLookUpMessage) {
try { return this.activeGateway().verifyLookUp(data);
console.log(data)
const body = await this.sender.verifyLookup(data);
if (!isKavenegarSuccess(body)) {
const normalized = normalizeKavenegarBody(body);
this.logProviderRejection(
"verifyLookUp",
data.receptor,
data.template,
normalized,
);
throw new SmsProviderException("verifyLookup", {
receptor: data.receptor,
template: data.template,
providerBody: body,
normalized,
});
}
this.logger.log(
`Kavenegar verifyLookUp ok receptor=${data.receptor} template=${data.template} status=200`,
);
return body;
} catch (e) {
if (e instanceof SmsProviderException) throw e;
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
this.logger.error(
`Kavenegar verifyLookUp transport error receptor=${data.receptor} template=${data.template} detail=${detail}`,
);
throw new SmsTransportException(
"verifyLookup",
{ receptor: data.receptor, template: data.template },
e,
);
}
} }
private logProviderRejection( private activeGateway(): KavenegarSmsGateway | ParsianSmsGateway {
op: "send" | "verifyLookUp", return getSmsProvider() === "parsian" ? this.parsian : this.kavenegar;
receptor: string,
template: string | undefined,
normalized: KavenegarNormalized,
) {
const t = template != null && template !== "" ? ` template=${template}` : "";
this.logger.warn(
`Kavenegar ${op} rejected receptor=${receptor}${t} providerStatus=${normalized.httpLikeStatus ?? "unknown"} providerMessage=${normalized.message ?? "n/a"} body=${normalized.raw}`,
);
} }
} }

View File

@@ -0,0 +1,13 @@
export interface SendMessage {
message: string;
receptor: string;
}
export interface VerifyLookUpMessage {
template: string;
token: string;
token2?: string;
token3?: string;
token10?: string;
receptor: string;
}

View File

@@ -0,0 +1,18 @@
export type SmsProviderId = "kavenegar" | "parsian";
/** Reads `SMS` (e.g. `parsian`) or `SMS_PROVIDER`; defaults to kavenegar. */
export function getSmsProvider(): SmsProviderId {
const raw = (
process.env.SMS ??
process.env.SMS_PROVIDER ??
"kavenegar"
)
.trim()
.toLowerCase();
if (raw === "parsian") return "parsian";
return "kavenegar";
}
export function isParsianSmsProvider(): boolean {
return getSmsProvider() === "parsian";
}

View File

@@ -5,9 +5,11 @@ import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
import { SmsGatewayModule } from "./provider/sms-gateway.module"; import { SmsGatewayModule } from "./provider/sms-gateway.module";
import { OtpGeneratorService } from "./otp-generator.service"; import { OtpGeneratorService } from "./otp-generator.service";
import { SmsOrchestrationService } from "./sms-orchestration.service"; import { SmsOrchestrationService } from "./sms-orchestration.service";
import { HttpModule } from "@nestjs/axios";
@Module({ @Module({
imports: [ imports: [
HttpModule,
SmsGatewayModule, SmsGatewayModule,
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]), MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
], ],

21
src/types/svg-captcha.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "svg-captcha" {
export interface CaptchaOptions {
size?: number;
ignoreChars?: string;
noise?: number;
color?: boolean;
background?: string;
width?: number;
height?: number;
fontSize?: number;
charPreset?: string;
}
export interface CaptchaResult {
data: string;
text: string;
}
export function create(options?: CaptchaOptions): CaptchaResult;
export function createMathExpr(options?: CaptchaOptions): CaptchaResult;
}

View File

@@ -72,7 +72,6 @@ export class ExpertModel {
@Prop({ type: "string" }) @Prop({ type: "string" })
otp: string; otp: string;
createdAt: Date; createdAt: Date;
} }

View File

@@ -1,95 +1,94 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { SubmitReply } from "src/request-management/entities/schema/request-management.schema"; import { SubmitReply } from "src/request-management/entities/schema/request-management.schema";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
@Injectable() @Injectable()
export class AutoCloseRequestService { export class AutoCloseRequestService {
private readonly logger = new Logger(AutoCloseRequestService.name); // private readonly logger = new Logger(AutoCloseRequestService.name);
constructor(private readonly requestDb: RequestManagementDbService) {} // constructor(private readonly requestDb: RequestManagementDbService) {}
async scheduleAutoClose(params: { requestId: string; runAt: Date }) { // async scheduleAutoClose(params: { requestId: string; runAt: Date }) {
await this.requestDb.findAndUpdate( // await this.requestDb.findAndUpdate(
{ _id: params.requestId }, // { _id: params.requestId },
{ $set: { autoCloseTriggerTime: params.runAt } }, // { $set: { autoCloseTriggerTime: params.runAt } },
); // );
this.logger.log( // this.logger.log(
`Scheduled auto-close for ${params.requestId} at ${params.runAt.toISOString()}`, // `Scheduled auto-close for ${params.requestId} at ${params.runAt.toISOString()}`,
); // );
} // }
// Runs every hour // // Runs every hour
@Cron(CronExpression.EVERY_HOUR) // @Cron(CronExpression.EVERY_HOUR)
async handleAutoClose() { // async handleAutoClose() {
const now = new Date(); // const now = new Date();
const threshold = new Date(now.getTime() - 24 * 60 * 60 * 1000); // const threshold = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const requestsToClose = await this.requestDb.findAll({ // const requestsToClose = await this.requestDb.findAll({
blameStatus: { $ne: ReqBlameStatus.CloseRequest }, // blameStatus: { $ne: ReqBlameStatus.CloseRequest },
$or: [ // $or: [
{ "expertSubmitReply.firstPartyComment.isAccept": true }, // { "expertSubmitReply.firstPartyComment.isAccept": true },
{ "expertSubmitReply.secondPartyComment.isAccept": true }, // { "expertSubmitReply.secondPartyComment.isAccept": true },
{ "expertSubmitReplyFinal.firstPartyComment.isAccept": true }, // { "expertSubmitReplyFinal.firstPartyComment.isAccept": true },
{ "expertSubmitReplyFinal.secondPartyComment.isAccept": true }, // { "expertSubmitReplyFinal.secondPartyComment.isAccept": true },
], // ],
autoCloseTriggerTime: { $lte: threshold }, // autoCloseTriggerTime: { $lte: threshold },
}); // });
for (const request of requestsToClose) { // for (const request of requestsToClose) {
const reply = request.expertSubmitReplyFinal || request.expertSubmitReply; // const reply = request.expertSubmitReplyFinal || request.expertSubmitReply;
const isSubmitReply = (reply: any): reply is SubmitReply => // const isSubmitReply = (reply: any): reply is SubmitReply =>
reply && // reply &&
typeof reply === "object" && // typeof reply === "object" &&
("firstPartyComment" in reply || "secondPartyComment" in reply); // ("firstPartyComment" in reply || "secondPartyComment" in reply);
if (isSubmitReply(reply)) { // if (isSubmitReply(reply)) {
const firstAccepted = reply.firstPartyComment?.isAccept; // const firstAccepted = reply.firstPartyComment?.isAccept;
const secondAccepted = reply.secondPartyComment?.isAccept; // const secondAccepted = reply.secondPartyComment?.isAccept;
if (!firstAccepted || !secondAccepted) { // if (!firstAccepted || !secondAccepted) {
await this.requestDb.findByIdAndUpdate(request._id.toString(), { // await this.requestDb.findByIdAndUpdate(request._id.toString(), {
blameStatus: ReqBlameStatus.CloseRequest, // blameStatus: ReqBlameStatus.CloseRequest,
}); // });
this.logger.log(`Auto-closed request ${request._id}`); // this.logger.log(`Auto-closed request ${request._id}`);
} // }
} // }
} // }
} // }
@Cron(CronExpression.EVERY_DAY_AT_2AM) // @Cron(CronExpression.EVERY_DAY_AT_2AM)
async handleBlameFileCleanup() { // async handleBlameFileCleanup() {
this.logger.log("Running scheduled blame file cleanup job..."); // this.logger.log("Running scheduled blame file cleanup job...");
const sevenDaysAgo = new Date(); // const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); // sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const staleStatuses = [ // const staleStatuses = [
ReqBlameStatus.PendingForFirstParty, // ReqBlameStatus.PendingForFirstParty,
ReqBlameStatus.PendingForSecondParty, // ReqBlameStatus.PendingForSecondParty,
]; // ];
const query = { // const query = {
createdAt: { $lt: sevenDaysAgo }, // createdAt: { $lt: sevenDaysAgo },
blameStatus: { $in: staleStatuses }, // blameStatus: { $in: staleStatuses },
}; // };
try { // try {
const result = await this.requestDb.updateMany(query, { // const result = await this.requestDb.updateMany(query, {
$set: { blameStatus: ReqBlameStatus.CloseRequest }, // $set: { blameStatus: ReqBlameStatus.CloseRequest },
}); // });
if (result.modifiedCount > 0) { // if (result.modifiedCount > 0) {
this.logger.log( // this.logger.log(
`Successfully closed ${result.modifiedCount} stale blame files.`, // `Successfully closed ${result.modifiedCount} stale blame files.`,
); // );
} else { // } else {
this.logger.log("No stale blame files found to close."); // this.logger.log("No stale blame files found to close.");
} // }
} catch (error) { // } catch (error) {
this.logger.error("Error during scheduled blame file cleanup:", error); // this.logger.error("Error during scheduled blame file cleanup:", error);
} // }
} // }
} }

View File

@@ -1,14 +1,26 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import * as bcrypt from "bcrypt"; import * as crypto from "node:crypto"
@Injectable() @Injectable()
export class HashService { export class HashService {
async hash(password: string): Promise<string> { hash(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10); return new Promise((resolve, reject) => {
return bcrypt.hash(password, salt); const salt = crypto.randomBytes(16).toString("hex");
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
const hash = `${salt}:${derivedKey.toString("hex")}`;
resolve(hash);
});
});
} }
async compare(password: string, hash: string) { compare(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash); return new Promise((resolve, reject) => {
const [salt, key] = hash.split(":");
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(key === derivedKey.toString("hex"));
});
});
} }
} }

View File

@@ -1,26 +0,0 @@
import { Module } from "@nestjs/common";
import { MailerModule } from "@nestjs-modules/mailer";
import { MailService } from "./mail.service";
@Module({
providers: [MailService],
exports: [MailService],
imports: [
MailerModule.forRoot({
transport: {
service: "gmail",
host: "smtp.gmail.com",
port: 465,
secure: true,
auth: {
user: "balali.arash@gmail.com",
pass: "macujakosnsqbgdm",
},
},
defaults: {
from: "KSG <modules@nestjs.com>",
},
}),
],
})
export class MailModule {}

View File

@@ -1,41 +0,0 @@
import { Injectable } from "@nestjs/common";
import { MailerService } from "@nestjs-modules/mailer";
@Injectable()
export class MailService {
constructor(private readonly mailerService: MailerService) {}
async sendMail(email, otp: string): Promise<any> {
let status: object;
await this.mailerService
.sendMail({
to: email,
from: "noreply@yara724.com",
subject: "YARA724 Company",
text: "welcome",
html: `
<h1 style="text-align:center">yara724 verificateion </h1>
<h3 style="background:#FFA500;width:150px; margin:0 auto; padding:10px; font-size:25px; border-radius:15px; text-align:center;">
<div style="color:white">
${otp}
</div>
</h3>
`,
})
.then((success) => {
if (success) {
status = {
message: "email was sent successfully",
code: 200,
emailSent: true,
};
}
})
.catch((err) => {
if (err) {
status = { message: "please try again", code: 500, emailSent: false };
}
});
return status;
}
}