forked from Yara724/api
Compare commits
42 Commits
39c4855b95
...
6ddb06594b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ddb06594b | |||
|
|
e90c6a5c50 | ||
| 97f26d400f | |||
|
|
02a69f3db2 | ||
| c137d6c6c4 | |||
|
|
2b7192151d | ||
| 389133e1c9 | |||
|
|
768d6d12fe | ||
|
|
84b752c6cc | ||
| 0622ceeaf4 | |||
|
|
3b0db0d250 | ||
| c502adbe76 | |||
|
|
7aada14551 | ||
| b3bf1b85f8 | |||
|
|
d6f1cb9eeb | ||
|
|
da298a3350 | ||
| 722cbbf5c9 | |||
|
|
245160bfc2 | ||
| 9c760d59f7 | |||
|
|
6ac0bf060e | ||
| 10df869efb | |||
| 5c372947dd | |||
|
|
9003a7abb6 | ||
| a994331439 | |||
|
|
ae12049e1b | ||
| 02031efdb5 | |||
|
|
48cc4d8a8d | ||
| cbbb45378d | |||
|
|
680f3c1798 | ||
|
|
64fa560f73 | ||
|
|
ff94fa35bf | ||
| 037d9fa934 | |||
|
|
2bdd0d507e | ||
| f60efa52b1 | |||
|
|
866696094f | ||
| ed936ad7b1 | |||
|
|
4d4106a8ab | ||
|
|
af875a4773 | ||
| 91221a6848 | |||
|
|
a31196774c | ||
| d2474d65bc | |||
|
|
94bde88cb6 |
72
.env.example
Normal file
72
.env.example
Normal 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
10
LICENSE
@@ -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/>
|
||||
1
err.json
1
err.json
@@ -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":""}}
|
||||
15901
package-lock.json
generated
15901
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
100
package.json
100
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yara724",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -16,80 +16,61 @@
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=10.0.0",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arashioz/errjson-talieh": "^2.2.5",
|
||||
"@fraybabak/kavenegar_nest": "^1.0.5",
|
||||
"@nestjs-modules/mailer": "^1.8.1",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.17",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/mongoose": "^10.1.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-fastify": "^10.4.15",
|
||||
"@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",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/serve-static": "^5.0.5",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"express": "^5.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.2",
|
||||
"jalali-moment": "^3.3.11",
|
||||
"kavenegar": "^1.1.4",
|
||||
"joi": "^18.2.1",
|
||||
"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",
|
||||
"rxjs": "^7.8.1",
|
||||
"short-unique-id": "^5.2.0",
|
||||
"standard": "^17.1.2",
|
||||
"standardjs": "^1.0.0-alpha",
|
||||
"uuid": "^11.0.3",
|
||||
"yargs": "^17.7.2"
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@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/jest": "^29.5.14",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-standard": "^12.0.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -106,9 +87,12 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@nestjs/core",
|
||||
"@swc/core"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ export function cloneResendUploadedDocuments(
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
if (item === ResendItemType.VOICE) return "voice";
|
||||
if (item === ResendItemType.VIDEO) return "video";
|
||||
if (item === ResendItemType.DESCRIPTION) return "text";
|
||||
return "document_camera";
|
||||
}
|
||||
@@ -80,13 +83,15 @@ export function isResendPartyItemSatisfied(
|
||||
): boolean {
|
||||
const uploaded = row.uploadedDocuments || {};
|
||||
if (item === ResendItemType.DESCRIPTION) {
|
||||
return !!(row.userTextDescription && String(row.userTextDescription).trim());
|
||||
return !!(
|
||||
row.userTextDescription && String(row.userTextDescription).trim()
|
||||
);
|
||||
}
|
||||
if (item === ResendItemType.VOICE) {
|
||||
return !!row.resendVoiceId;
|
||||
}
|
||||
if (item === ResendItemType.VIDEO) {
|
||||
return !!row.resendVideoId;
|
||||
}
|
||||
// if (item === ResendItemType.VIDEO) {
|
||||
// return !!row.resendVideoId;
|
||||
// }
|
||||
return !!uploaded[item];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ export enum ResendItemType {
|
||||
CAR_CERTIFICATE = "carCertificate",
|
||||
DRIVING_LICENSE = "drivingLicense",
|
||||
CAR_GREEN_CARD = "carGreenCard",
|
||||
PLATE = "plate",
|
||||
CAR_PLATE = "carPlate",
|
||||
CHASSIS_NUMBER = "chassisNumber",
|
||||
// Media evidence
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
|
||||
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||
DESCRIPTION = "description",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export enum ClaimRequiredDocumentType {
|
||||
// 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_CARD = "national_card",
|
||||
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import * as FormData from "form-data";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
|
||||
|
||||
@Injectable()
|
||||
export class AiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private apiKey: string;
|
||||
private accessToken: string = null;
|
||||
|
||||
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
|
||||
private readonly loginOptions: AxiosRequestConfig = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
url: `${process.env.AI_URL_V2}/auth/login`,
|
||||
url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
|
||||
data: {
|
||||
username: process.env.AI_USERNAME,
|
||||
password: process.env.AI_PASSWORD,
|
||||
username: this.configService.get<string>("AI_USERNAME"),
|
||||
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 {
|
||||
return {
|
||||
method: "GET",
|
||||
url: `${process.env.AI_URL_V2}/auth/profile`,
|
||||
url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
const res = await this.login();
|
||||
if (res?.accessToken) {
|
||||
this.logger.verbose("AI Service Authenticated Successfully.");
|
||||
this.accessToken = res.accessToken;
|
||||
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.",
|
||||
);
|
||||
}
|
||||
// if (res?.accessToken) {
|
||||
// this.accessToken = res.accessToken;
|
||||
// await this.getApiKey();
|
||||
// }
|
||||
} catch (error) {
|
||||
// Don't prevent app startup if AI service is temporarily unavailable
|
||||
// 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
|
||||
this.accessToken = null;
|
||||
this.apiKey = null;
|
||||
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async login() {
|
||||
try {
|
||||
const loginResponse = await axios.request(this.loginOptions);
|
||||
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,
|
||||
);
|
||||
}
|
||||
// const loginResponse = await axios.request(this.loginOptions);
|
||||
// return loginResponse.data;
|
||||
}
|
||||
|
||||
private async getApiKey() {
|
||||
try {
|
||||
const profileResponse = await axios.request(this.profileOptions);
|
||||
this.apiKey = profileResponse.data.apiKey.key;
|
||||
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,
|
||||
);
|
||||
}
|
||||
// const profileResponse = await axios.request(this.profileOptions);
|
||||
// this.apiKey = profileResponse.data.apiKey.key;
|
||||
// return this.apiKey;
|
||||
}
|
||||
|
||||
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
|
||||
if (!this.accessToken || !this.apiKey) {
|
||||
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
|
||||
try {
|
||||
const res = await this.login();
|
||||
if (res?.accessToken) {
|
||||
this.accessToken = res.accessToken;
|
||||
await this.getApiKey();
|
||||
} else {
|
||||
throw new HttpException(
|
||||
"AI Service authentication failed",
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
// if (res?.accessToken) {
|
||||
// this.accessToken = res.accessToken;
|
||||
// await this.getApiKey();
|
||||
// } else {
|
||||
// throw new HttpException(
|
||||
// "AI Service authentication failed",
|
||||
// HttpStatus.UNAUTHORIZED,
|
||||
// );
|
||||
// }
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to re-authenticate AI service:", error.message);
|
||||
throw new HttpException(
|
||||
"AI Service authentication failed",
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
@@ -140,100 +107,69 @@ export class AiService implements OnModuleInit {
|
||||
? file.path
|
||||
: join(process.cwd(), file.path.replace(/^\.\//, ""));
|
||||
|
||||
this.logger.log(`Processing AI image request for: ${filePath}`);
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(filePath)) {
|
||||
this.logger.error(`File not found at path: ${filePath}`);
|
||||
throw new HttpException(
|
||||
`File not found: ${file.path}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
// const form = new FormData();
|
||||
const fileStream = createReadStream(filePath);
|
||||
|
||||
// Append file with filename if available
|
||||
if (file.fileName) {
|
||||
form.append("images", fileStream, file.fileName);
|
||||
// form.append("images", fileStream, file.fileName);
|
||||
} else {
|
||||
// Extract filename from path if not provided
|
||||
const pathParts = filePath.split("/");
|
||||
const extractedFileName = pathParts[pathParts.length - 1];
|
||||
form.append("images", fileStream, extractedFileName);
|
||||
// form.append("images", fileStream, extractedFileName);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestHeaders = {
|
||||
...this.imageProcessOptions.headers,
|
||||
...form.getHeaders(),
|
||||
// ...form.getHeaders(),
|
||||
};
|
||||
|
||||
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`);
|
||||
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 fs = require("fs");
|
||||
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({
|
||||
...this.imageProcessOptions,
|
||||
headers: requestHeaders,
|
||||
data: form,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
|
||||
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`);
|
||||
// const response = await axios.request({
|
||||
// ...this.imageProcessOptions,
|
||||
// headers: requestHeaders,
|
||||
// // data: form,
|
||||
// maxContentLength: Infinity,
|
||||
// maxBodyLength: Infinity,
|
||||
// });
|
||||
|
||||
// Validate response structure
|
||||
if (!response.data) {
|
||||
this.logger.error(`[ERROR] AI response is empty or missing data`);
|
||||
throw new HttpException(
|
||||
"AI Service returned empty response",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// if (!response.data) {
|
||||
// throw new HttpException(
|
||||
// "AI Service returned empty response",
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for error in response first (AI service returns 201 with error in body)
|
||||
if (response.data.error) {
|
||||
this.logger.error(`[ERROR] AI service returned an error in response body`);
|
||||
this.logger.error(`[ERROR] Error message: ${response.data.error}`);
|
||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
throw new HttpException(
|
||||
`AI Service error: ${response.data.error}`,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// // Check for error in response first (AI service returns 201 with error in body)
|
||||
// if (response.data.error) {
|
||||
// throw new HttpException(
|
||||
// `AI Service error: ${response.data.error}`,
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for processed image (downloadLink)
|
||||
if (!response.data.downloadLink) {
|
||||
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`);
|
||||
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`);
|
||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
throw new HttpException(
|
||||
"AI Service did not return processed image (downloadLink missing)",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// // Check for processed image (downloadLink)
|
||||
// if (!response.data.downloadLink) {
|
||||
// throw new HttpException(
|
||||
// "AI Service did not return processed image (downloadLink missing)",
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for reports
|
||||
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;
|
||||
// return response.data;
|
||||
} catch (er) {
|
||||
// Determine error source
|
||||
let errorSource = "UNKNOWN";
|
||||
@@ -242,7 +178,10 @@ export class AiService implements OnModuleInit {
|
||||
|
||||
if (er.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
|
||||
? JSON.stringify(er.response.data, null, 2)
|
||||
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
|
||||
@@ -255,14 +194,6 @@ export class AiService implements OnModuleInit {
|
||||
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
|
||||
throw new HttpException(
|
||||
`[${errorSource}] ${errorMessage}`,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { join } from "node:path";
|
||||
import { Module, ValidationPipe } from "@nestjs/common";
|
||||
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 { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
import * as dotenv from "dotenv";
|
||||
import { CommandModule } from "nestjs-command";
|
||||
import { AiModule } from "./ai/ai.module";
|
||||
import { AuthModule } from "./auth/auth.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 { CronModule } from "./utils/cron/cron.module";
|
||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
import * as Joi from "joi";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CommandModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ConfigModule.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,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, "..", "files"),
|
||||
serveRoot: "/files",
|
||||
}),
|
||||
MongooseModule.forRoot(
|
||||
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
|
||||
{
|
||||
dbName: "yara724",
|
||||
autoIndex: true,
|
||||
user: process.env.MONGO_USER,
|
||||
pass: process.env.MONGO_PASS,
|
||||
authMechanism: "SCRAM-SHA-256",
|
||||
tls: true,
|
||||
tlsAllowInvalidCertificates: true,
|
||||
MongooseModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
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")}`,
|
||||
tls: configService.get<string>("MONGO_TLS") === "true",
|
||||
tlsAllowInvalidCertificates:
|
||||
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") ===
|
||||
"true",
|
||||
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
||||
connectionFactory: (connection) => {
|
||||
applyIranFaTimestampPlugin(connection);
|
||||
return connection;
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ClientModule,
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import {
|
||||
ApiBody,
|
||||
ApiAcceptedResponse,
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
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 {
|
||||
ForgetPasswordSendCodeDto,
|
||||
ForgetPasswordVerifyCodeDto,
|
||||
@@ -37,7 +43,52 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
@Controller("actor")
|
||||
@ApiTags("actor")
|
||||
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
|
||||
@@ -113,6 +164,8 @@ export class ActorAuthController {
|
||||
role: "company",
|
||||
username: "saman_insurer@gmail.com",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
expert: {
|
||||
@@ -122,6 +175,8 @@ export class ActorAuthController {
|
||||
role: "expert",
|
||||
username: "blame@gmail.com",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
damage_expert: {
|
||||
@@ -131,6 +186,8 @@ export class ActorAuthController {
|
||||
role: "damage_expert",
|
||||
username: "claim@gmail.com",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ export class UserAuthController {
|
||||
linkToken: body.linkToken,
|
||||
linkContext: body.linkContext,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
throw new HttpException(res, HttpStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RegisterDtoRs,
|
||||
} from "src/auth/dto/actor/register.actor.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 { RoleEnum } from "src/Types&Enums/role.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 { RegistrarDbService } from "src/users/entities/db-service/registrar.db.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";
|
||||
|
||||
function pick(obj: Record<string, any>, keys: string[]) {
|
||||
@@ -49,9 +49,9 @@ export class ActorAuthService {
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly registrarDbService: RegistrarDbService,
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||
) {}
|
||||
|
||||
// TODO convrt to class for dynamic controller
|
||||
@@ -78,8 +78,7 @@ export class ActorAuthService {
|
||||
res = await this.fieldExpertDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
else res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
@@ -139,7 +138,7 @@ export class ActorAuthService {
|
||||
};
|
||||
|
||||
const access_token = this.jwtService.sign(payload, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
|
||||
@@ -159,9 +158,7 @@ export class ActorAuthService {
|
||||
throw new UnauthorizedException("user not assigned to this role");
|
||||
}
|
||||
if (!(await this.hashService.compare(pass, user.password))) {
|
||||
throw new UnauthorizedException(
|
||||
"password is incorrect or access Denied",
|
||||
);
|
||||
throw new UnauthorizedException("password is incorrect or access Denied");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -176,6 +173,11 @@ export class ActorAuthService {
|
||||
if (typeof password !== "string" || !password) {
|
||||
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);
|
||||
return this.issueActorTokens(actor);
|
||||
}
|
||||
@@ -440,13 +442,7 @@ export class ActorAuthService {
|
||||
"state",
|
||||
"address",
|
||||
],
|
||||
field_expert: [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
],
|
||||
field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
|
||||
registrar: ["email"],
|
||||
};
|
||||
|
||||
@@ -472,7 +468,11 @@ export class ActorAuthService {
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
|
||||
38
src/auth/auth-services/captcha-auth.error.ts
Normal file
38
src/auth/auth-services/captcha-auth.error.ts
Normal 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));
|
||||
}
|
||||
@@ -34,9 +34,9 @@ export function userAuthErrorBody(code: UserAuthErrorCode) {
|
||||
}
|
||||
|
||||
export function throwUserAuthError(code: UserAuthErrorCode): never {
|
||||
if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||
throw new BadRequestException(userAuthErrorBody(code));
|
||||
}
|
||||
// if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||
// throw new BadRequestException(userAuthErrorBody(code));
|
||||
// }
|
||||
|
||||
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
|
||||
throw new ForbiddenException(userAuthErrorBody(code));
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} 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 { normalizeIranMobile } from "src/helpers/iran-mobile";
|
||||
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||
@@ -20,6 +22,8 @@ export class UserLinkAccessService {
|
||||
private readonly blameRequestModel: Model<BlameRequest>,
|
||||
@InjectModel(ClaimRequestManagementModel.name)
|
||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||
@InjectModel(ClaimCase.name)
|
||||
private readonly claimCaseModel: Model<ClaimCase>,
|
||||
private readonly userDbService: UserDbService,
|
||||
) {}
|
||||
|
||||
@@ -39,10 +43,12 @@ export class UserLinkAccessService {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
||||
}
|
||||
|
||||
const normalizedMobile = this.normalizePhone(params.mobile);
|
||||
const normalizedMobile = normalizeIranMobile(params.mobile);
|
||||
if (
|
||||
!normalizedMobile ||
|
||||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
|
||||
!allowedMobiles.some(
|
||||
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
|
||||
)
|
||||
) {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
||||
}
|
||||
@@ -58,15 +64,18 @@ export class UserLinkAccessService {
|
||||
const context = this.normalizeContext(linkContext);
|
||||
const allowedMobiles = new Set<string>();
|
||||
|
||||
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
|
||||
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
|
||||
await Promise.all([
|
||||
this.requestManagementModel.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.addBlameRequestPhones(allowedMobiles, blameRequest, context);
|
||||
await this.addClaimOwnerPhone(allowedMobiles, claimRequest);
|
||||
await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
|
||||
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
|
||||
|
||||
return Array.from(allowedMobiles);
|
||||
}
|
||||
@@ -119,7 +128,7 @@ export class UserLinkAccessService {
|
||||
}
|
||||
}
|
||||
|
||||
private async addClaimOwnerPhone(
|
||||
private async addLegacyClaimOwnerPhone(
|
||||
allowedMobiles: Set<string>,
|
||||
claimRequest: any,
|
||||
) {
|
||||
@@ -146,29 +155,51 @@ export class UserLinkAccessService {
|
||||
_id: new Types.ObjectId(ownerUserIdText),
|
||||
});
|
||||
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) {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
const normalized = normalizeIranMobile(phone);
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,15 @@ import {
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||
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 { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
@@ -17,7 +26,6 @@ export interface LinkBinding {
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||
@Injectable()
|
||||
export class UserAuthService {
|
||||
private readonly logger = new Logger(UserAuthService.name);
|
||||
@@ -36,18 +44,21 @@ export class UserAuthService {
|
||||
pass: string,
|
||||
binding: LinkBinding = {},
|
||||
): Promise<any> {
|
||||
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
|
||||
|
||||
await this.userLinkAccessService.assertMobileAllowed({
|
||||
mobile: username,
|
||||
mobile: canonicalMobile,
|
||||
linkToken: binding.linkToken,
|
||||
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);
|
||||
|
||||
const now = new Date().getTime();
|
||||
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
|
||||
if (user.otpExpire < now) {
|
||||
if (!isOtpExpiryActive(user.otpExpire)) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
||||
}
|
||||
if (await this.hashService.compare(pass, user.otp)) {
|
||||
@@ -57,23 +68,25 @@ export class UserAuthService {
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
const userId = String(user._id ?? user.id ?? "");
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.id,
|
||||
sub: userId,
|
||||
role: "user",
|
||||
};
|
||||
const accToken = this.jwtService.sign(payload, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
await this.userDbService.findOneAndUpdate(
|
||||
{ username: user.username },
|
||||
{
|
||||
tokens: { token: accToken },
|
||||
otp: null,
|
||||
otpExpire: 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
userId: user._id,
|
||||
userId,
|
||||
access_token: accToken,
|
||||
};
|
||||
}
|
||||
@@ -82,29 +95,31 @@ export class UserAuthService {
|
||||
mobile: string,
|
||||
binding: LinkBinding = {},
|
||||
): Promise<LoginDtoRs> {
|
||||
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
|
||||
if (!canonicalMobile) {
|
||||
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.userLinkAccessService.assertMobileAllowed({
|
||||
mobile,
|
||||
mobile: canonicalMobile,
|
||||
linkToken: binding.linkToken,
|
||||
linkContext: binding.linkContext,
|
||||
});
|
||||
|
||||
const userExist = await this.userDbService.findOne({
|
||||
mobile,
|
||||
});
|
||||
const userExist = await this.userDbService.findOne(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
);
|
||||
const otp = this.otpCreator.create();
|
||||
const hashOtp = await this.hashService.hash(otp);
|
||||
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
|
||||
const expireMinutes =
|
||||
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
|
||||
? rawExpireMinutes
|
||||
: 2;
|
||||
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
|
||||
const expireMinutes = readOtpExpireMinutesFromEnv();
|
||||
const nowMs = Date.now();
|
||||
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
|
||||
|
||||
if (!userExist) {
|
||||
await this.smsSender(otp, mobile);
|
||||
/// create otp request
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
const newUser = await this.userDbService.createUser({
|
||||
mobile,
|
||||
username: mobile,
|
||||
mobile: canonicalMobile,
|
||||
username: canonicalMobile,
|
||||
otp: hashOtp,
|
||||
tokens: {
|
||||
token: "",
|
||||
@@ -122,22 +137,23 @@ export class UserAuthService {
|
||||
});
|
||||
return new LoginDtoRs(newUser);
|
||||
}
|
||||
if (userExist) {
|
||||
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
||||
|
||||
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
|
||||
// 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,
|
||||
},
|
||||
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
await this.userDbService.findOneAndUpdate(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
{
|
||||
otp: hashOtp,
|
||||
otpExpire,
|
||||
mobile: canonicalMobile,
|
||||
username: userExist.username || canonicalMobile,
|
||||
},
|
||||
);
|
||||
if (updateTokens) return new LoginDtoRs(userExist);
|
||||
}
|
||||
return new LoginDtoRs(userExist);
|
||||
}
|
||||
|
||||
private async smsSender(otp: string, mobile: string) {
|
||||
@@ -147,10 +163,7 @@ export class UserAuthService {
|
||||
process.env.AUTH_SMS_TEMPLATE,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new HttpException(
|
||||
"auth sms send failed",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
this.logger.log(
|
||||
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
|
||||
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
|
||||
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
|
||||
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.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 {
|
||||
ClaimRequestManagementModel,
|
||||
ClaimRequestManagementSchema,
|
||||
@@ -24,16 +27,17 @@ import {
|
||||
} from "src/request-management/entities/schema/request-management.schema";
|
||||
import { UsersModule } from "src/users/users.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 { CaptchaModule } from "src/captcha/captcha.module";
|
||||
|
||||
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
// MailModule, // Mailer disabled – not used
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
HashModule,
|
||||
PassportModule,
|
||||
CaptchaModule,
|
||||
SmsOrchestrationModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
|
||||
@@ -42,22 +46,29 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
name: ClaimRequestManagementModel.name,
|
||||
schema: ClaimRequestManagementSchema,
|
||||
},
|
||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||
]),
|
||||
JwtModule.register({
|
||||
signOptions: { expiresIn: "1h" },
|
||||
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
|
||||
global: true,
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
UserAuthService,
|
||||
UserLinkAccessService,
|
||||
ActorAuthService,
|
||||
LocalStrategy,
|
||||
LocalActorStrategy,
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
],
|
||||
exports: [
|
||||
UserAuthService,
|
||||
ActorAuthService,
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
],
|
||||
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
|
||||
controllers: [UserAuthController, ActorAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
export class LoginActorDto {
|
||||
@@ -10,6 +11,24 @@ export class LoginActorDto {
|
||||
|
||||
@ApiProperty({})
|
||||
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 {
|
||||
|
||||
28
src/auth/dto/captcha-response.dto.ts
Normal file
28
src/auth/dto/captcha-response.dto.ts
Normal 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;
|
||||
}
|
||||
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal 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";
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export class UserLoginDto {
|
||||
}
|
||||
|
||||
export class LoginDtoRs extends UserModel {
|
||||
@ApiProperty({ type: "string" })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
@@ -61,8 +63,10 @@ export class LoginDtoRs extends UserModel {
|
||||
username: string;
|
||||
mobile: string;
|
||||
nationalCode: string;
|
||||
constructor(loginData) {
|
||||
|
||||
constructor(loginData, message: string = "") {
|
||||
super();
|
||||
this.mobile = loginData.mobile;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
@Injectable()
|
||||
export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
export class LocalActorAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly actorAuthService: ActorAuthService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
const path = request.url;
|
||||
|
||||
if (!token) {
|
||||
if (this.isActorLoginRequest(request)) {
|
||||
@@ -30,14 +27,13 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
request.user = loginData;
|
||||
request.identity = loginData;
|
||||
return true;
|
||||
} else {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -72,9 +68,10 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
return path === "/actor/login" || path.endsWith("/actor/login");
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
//@ts-ignore
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
private extractTokenFromHeader(request: {
|
||||
headers?: { authorization?: string };
|
||||
}): string | undefined {
|
||||
const [type, token] = request.headers?.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
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)
|
||||
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
|
||||
throw new UnauthorizedException("Invalid role");
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
|
||||
if (
|
||||
error instanceof ForbiddenException ||
|
||||
error instanceof UnauthorizedException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,13 @@ export class GlobalGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SettingsJwtGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
(request as any).user = payload;
|
||||
(request as any).identity = payload;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
@@ -7,13 +10,12 @@ import {
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalUserAuthGuard extends AuthGuard("local") {
|
||||
constructor(private readonly userAuthService: UserAuthService) {
|
||||
super();
|
||||
}
|
||||
export class LocalUserAuthGuard implements CanActivate {
|
||||
constructor(private readonly userAuthService: UserAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
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(
|
||||
username,
|
||||
password,
|
||||
@@ -22,7 +24,7 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
|
||||
if (!isValidUser) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
request["user"] = isValidUser;
|
||||
request.user = isValidUser;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
100
src/captcha/captcha-challenge.service.ts
Normal file
100
src/captcha/captcha-challenge.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
28
src/captcha/captcha.module.ts
Normal file
28
src/captcha/captcha.module.ts
Normal 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 {}
|
||||
43
src/captcha/captcha.service.ts
Normal file
43
src/captcha/captcha.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal 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);
|
||||
@@ -30,6 +30,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
|
||||
@@ -93,7 +94,7 @@ export class ClaimRequestManagementController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-green-cards",
|
||||
@@ -126,9 +127,7 @@ export class ClaimRequestManagementController {
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
) {
|
||||
async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
|
||||
return await this.claimRequestManagementService.getRequiredDocumentsStatus(
|
||||
requestId,
|
||||
);
|
||||
@@ -153,7 +152,7 @@ export class ClaimRequestManagementController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-required-documents/",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -206,7 +205,7 @@ export class ClaimRequestManagementController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-parts/",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -257,7 +256,7 @@ export class ClaimRequestManagementController {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -292,10 +291,7 @@ export class ClaimRequestManagementController {
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
myRequests(
|
||||
@Param("claimRequestId") requestId: string,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
|
||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||
}
|
||||
|
||||
@@ -305,7 +301,7 @@ export class ClaimRequestManagementController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
@@ -330,12 +326,12 @@ export class ClaimRequestManagementController {
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
return await this.claimRequestManagementService.submitUserReply(
|
||||
requestId,
|
||||
body,
|
||||
file,
|
||||
user,
|
||||
);
|
||||
// return await this.claimRequestManagementService.submitUserReply(
|
||||
// requestId,
|
||||
// body,
|
||||
// file,
|
||||
// user,
|
||||
// );
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@@ -386,7 +382,7 @@ export class ClaimRequestManagementController {
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
}),
|
||||
)
|
||||
async uploadDocuments(
|
||||
@@ -449,7 +445,7 @@ export class ClaimRequestManagementController {
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
}),
|
||||
)
|
||||
async uploadFactorForPart(
|
||||
|
||||
@@ -11,7 +11,10 @@ import { ClaimRequestManagementService } from "./claim-request-management.servic
|
||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.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 { DamageImageDbService } from "./entites/db-service/damage-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 { JwtModule } from "@nestjs/jwt";
|
||||
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
PublicIdModule,
|
||||
UsersModule,
|
||||
RequestManagementModule,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,14 @@ import { readFile } from "node:fs/promises";
|
||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { extname } from "node:path";
|
||||
import { Types } from "mongoose";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
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 { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import {
|
||||
@@ -289,7 +290,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiResponse({ status: 409, description: "Already signed" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -566,7 +567,7 @@ Optional: upload car green card file in the same step.
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-required-document",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -706,7 +707,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
@@ -814,7 +815,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
@@ -934,7 +935,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
callback(null, `-${unique}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
}),
|
||||
)
|
||||
async uploadFactorForPartV2(
|
||||
@@ -973,7 +974,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
|
||||
@@ -67,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
resendDocuments?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
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' })
|
||||
fulfilledAt?: Date;
|
||||
|
||||
@@ -66,6 +66,13 @@ export class ClaimWorkflow {
|
||||
|
||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||
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);
|
||||
|
||||
|
||||
@@ -159,5 +159,5 @@ export class ClaimCase {
|
||||
|
||||
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
||||
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
||||
|
||||
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prop, Schema } from "@nestjs/mongoose";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@Schema({ versionKey: false, _id: false })
|
||||
export class ImageRequiredModel {
|
||||
@@ -20,7 +20,7 @@ export class ImageRequiredModel {
|
||||
constructor(claimFile: any[]) {
|
||||
this.aroundTheCar.forEach((a) => {
|
||||
Object.assign(a, {
|
||||
partId: uuidv4(),
|
||||
partId: randomUUID(),
|
||||
imageId: null,
|
||||
aiReport: {},
|
||||
upload: false,
|
||||
@@ -28,7 +28,7 @@ export class ImageRequiredModel {
|
||||
});
|
||||
this.selectPartOfCar = claimFile.map((c, idx) =>
|
||||
Object.assign(c, {
|
||||
partId: uuidv4(),
|
||||
partId: randomUUID(),
|
||||
aiReport: {},
|
||||
imageId: null,
|
||||
upload: false,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
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 { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
|
||||
@@ -83,7 +84,7 @@ export class RegistrarClaimV1Controller {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
@@ -110,7 +111,7 @@ export class RegistrarClaimV1Controller {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
|
||||
@@ -23,14 +23,19 @@ import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
|
||||
* 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
|
||||
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
|
||||
* per-client override under `settings.media.<kind>`.
|
||||
*/
|
||||
export type MediaKind = "video" | "image" | "voice";
|
||||
export type MediaKind =
|
||||
| "video"
|
||||
| "image"
|
||||
| "voice"
|
||||
| "chassisNumber"
|
||||
| "carPlate";
|
||||
|
||||
export interface MediaLimits {
|
||||
/** 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
|
||||
* 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> = {
|
||||
video: { minBytes: 256 * 1024, maxBytes: 20 * 1024 * 1024 },
|
||||
image: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 },
|
||||
voice: { minBytes: 5 * 1024, maxBytes: 8 * 1024 * 1024 },
|
||||
video: {
|
||||
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||
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). */
|
||||
export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
||||
video: 50 * 1024 * 1024,
|
||||
image: 10 * 1024 * 1024,
|
||||
voice: 10 * 1024 * 1024,
|
||||
video: DEFAULT_MEDIA_MAX_BYTES,
|
||||
image: DEFAULT_MEDIA_MAX_BYTES,
|
||||
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;
|
||||
@@ -173,13 +200,19 @@ export class ClientService {
|
||||
_id: new Types.ObjectId(idString),
|
||||
});
|
||||
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 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();
|
||||
if (!Types.ObjectId.isValid(raw)) {
|
||||
throw new BadRequestException("Invalid client key");
|
||||
@@ -281,7 +314,8 @@ export class ClientService {
|
||||
const $set: Record<string, unknown> = {};
|
||||
|
||||
if (body.carBodyAccidentMaxAgeDays !== undefined) {
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays;
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] =
|
||||
body.carBodyAccidentMaxAgeDays;
|
||||
}
|
||||
|
||||
if (body.media) {
|
||||
|
||||
38
src/common/dto/expert-file-assign-result.dto.ts
Normal file
38
src/common/dto/expert-file-assign-result.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { LoggingInterceptor } from "src/interceptor/logging.interceptors";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import {
|
||||
ResendFirstPartyDto,
|
||||
@@ -135,7 +134,6 @@ export class ExpertBlameController {
|
||||
return this.expertBlameService.streamVideo(requestId);
|
||||
}
|
||||
|
||||
@UseInterceptors(LoggingInterceptor)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("voice/:requestId/:voiceId")
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
@@ -23,6 +24,16 @@ import {
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import {
|
||||
@@ -71,13 +82,11 @@ interface CheckedRequestEntry {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function statementToFormKey(
|
||||
statement?: {
|
||||
function statementToFormKey(statement?: {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
acceptsExpertOpinion?: boolean;
|
||||
},
|
||||
): string | null {
|
||||
}): string | null {
|
||||
if (!statement) return null;
|
||||
if (statement.admitsGuilt) return "imGuilty";
|
||||
if (statement.claimsDamage) return "imDamaged";
|
||||
@@ -140,7 +149,8 @@ export class ExpertBlameService {
|
||||
const requestsNeedingExpert = [];
|
||||
for (const request of allRequests) {
|
||||
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
|
||||
const secondPartyForm = request.secondPartyDetails?.secondPartyInitialForm;
|
||||
const secondPartyForm =
|
||||
request.secondPartyDetails?.secondPartyInitialForm;
|
||||
|
||||
if (!firstPartyForm || !secondPartyForm) {
|
||||
continue; // Skip if forms are not complete
|
||||
@@ -274,6 +284,14 @@ export class ExpertBlameService {
|
||||
buckets.all++;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -298,7 +316,8 @@ export class ExpertBlameService {
|
||||
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
||||
// 3. Requests decided by current expert
|
||||
// 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(
|
||||
(doc) => {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||
return false;
|
||||
}
|
||||
@@ -329,13 +348,20 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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>();
|
||||
for (const doc of visibleCases) {
|
||||
@@ -346,7 +372,9 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
|
||||
[...staleIds].map((id) =>
|
||||
this.expireBlameCaseWorkflowLockV2IfStale(id),
|
||||
),
|
||||
);
|
||||
for (const doc of visibleCases) {
|
||||
if (!staleIds.has(String(doc._id))) continue;
|
||||
@@ -359,8 +387,11 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(visibleCases, {
|
||||
publicId: (doc) => String((doc as { publicId?: string }).publicId ?? ""),
|
||||
const paged = applyListQueryV2(
|
||||
visibleCases,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
@@ -375,13 +406,13 @@ export class ExpertBlameService {
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
};
|
||||
return [
|
||||
String(d._id ?? ""),
|
||||
d.blameStatus,
|
||||
d.type,
|
||||
].filter(Boolean) as string[];
|
||||
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
},
|
||||
}, query);
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
@@ -396,7 +427,10 @@ export class ExpertBlameService {
|
||||
});
|
||||
} catch (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(
|
||||
error instanceof Error ? error.message : "Failed to list blame cases",
|
||||
);
|
||||
@@ -406,8 +440,12 @@ export class ExpertBlameService {
|
||||
private mapBlameRequestToListItemV2(
|
||||
doc: Record<string, unknown>,
|
||||
): AllRequestDtoV2 {
|
||||
const createdAt = doc.createdAt ? new Date(doc.createdAt as string) : new Date();
|
||||
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string) : new Date();
|
||||
const createdAt = doc.createdAt
|
||||
? 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 [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
|
||||
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
||||
@@ -462,8 +500,7 @@ export class ExpertBlameService {
|
||||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
|
||||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||
"",
|
||||
}
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -483,9 +520,7 @@ export class ExpertBlameService {
|
||||
}
|
||||
const la = doc.workflow.lockedAt;
|
||||
if (!la) return true;
|
||||
return (
|
||||
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
|
||||
);
|
||||
return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,16 +537,12 @@ export class ExpertBlameService {
|
||||
|
||||
const lockedAt = request.workflow.lockedAt;
|
||||
const expiredAt = request.workflow.expiredAt;
|
||||
if (
|
||||
expiredAt &&
|
||||
Date.now() < new Date(expiredAt as Date).getTime()
|
||||
) {
|
||||
if (expiredAt && Date.now() < new Date(expiredAt as Date).getTime()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lockedAt &&
|
||||
Date.now() <
|
||||
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
|
||||
Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
|
||||
) {
|
||||
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(
|
||||
filter as any,
|
||||
{
|
||||
$set: { "workflow.locked": false },
|
||||
$set: expireSet,
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
"workflow.expiredAt": "",
|
||||
@@ -652,8 +691,12 @@ export class ExpertBlameService {
|
||||
requestId: String(current._id),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
String(
|
||||
current.firstPartyDetails?.firstPartyClient?.clientId ?? "",
|
||||
) ||
|
||||
String(
|
||||
current.secondPartyDetails?.secondPartyClient?.clientId ?? "",
|
||||
),
|
||||
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
|
||||
});
|
||||
|
||||
@@ -806,16 +849,20 @@ export class ExpertBlameService {
|
||||
* Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence.
|
||||
* 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 {
|
||||
requireActorClientKey(actor);
|
||||
// requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||
const doc =
|
||||
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException("Request not found");
|
||||
}
|
||||
assertBlameCaseForExpertTenant(doc, actor);
|
||||
// assertBlameCaseForExpertTenant(doc, actor);
|
||||
const type = doc.type as string;
|
||||
if (type === BlameRequestType.CAR_BODY) {
|
||||
throw new ForbiddenException(
|
||||
@@ -826,14 +873,20 @@ export class ExpertBlameService {
|
||||
// Access control
|
||||
const expertInitiated = doc.expertInitiated === true;
|
||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
|
||||
if (
|
||||
expertInitiated &&
|
||||
initiatedByFieldExpertId &&
|
||||
String(initiatedByFieldExpertId) !== actorId
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"Only the field expert who created this file can view and review it.",
|
||||
);
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
if (lockerId && lockerId !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
@@ -872,14 +925,18 @@ export class ExpertBlameService {
|
||||
const evidence = party.evidence as Record<string, unknown>;
|
||||
|
||||
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 (evidence.voices && Array.isArray(evidence.voices)) {
|
||||
const voiceUrls: string[] = [];
|
||||
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));
|
||||
}
|
||||
evidence.voiceUrls = voiceUrls;
|
||||
@@ -887,9 +944,12 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date();
|
||||
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date();
|
||||
|
||||
const createdAt = doc.createdAt
|
||||
? 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 [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
||||
@@ -916,12 +976,13 @@ export class ExpertBlameService {
|
||||
);
|
||||
|
||||
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) {
|
||||
const existing = await this.requestManagementDbService.findOne(requestId);
|
||||
if (!existing) {
|
||||
@@ -937,7 +998,8 @@ export class ExpertBlameService {
|
||||
const lockedByCurrent =
|
||||
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||||
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.
|
||||
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||||
@@ -990,8 +1052,12 @@ export class ExpertBlameService {
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
tenantId:
|
||||
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
String(
|
||||
updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "",
|
||||
) ||
|
||||
String(
|
||||
updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? "",
|
||||
),
|
||||
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
|
||||
});
|
||||
|
||||
@@ -1000,118 +1066,254 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Lock blame case for 15 minutes (blameCases collection).
|
||||
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt.
|
||||
* Checks if existing lock is expired and allows re-locking.
|
||||
* Assign the current field expert to a blame case for review (V2).
|
||||
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||
*/
|
||||
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
async assignBlameCaseForReviewV2(
|
||||
requestId: string,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
// First, check the current state
|
||||
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
if (!request) {
|
||||
throw new NotFoundException("Request not found");
|
||||
}
|
||||
|
||||
assertBlameCaseForExpertTenant(request, actorDetail);
|
||||
assertBlameCaseForExpertTenant(request, actor);
|
||||
|
||||
if (request.type === BlameRequestType.CAR_BODY) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "CAR_BODY cases do not require expert review",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate request is available for expert review
|
||||
if (
|
||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
||||
request.blameStatus !== BlameStatus.DISAGREEMENT
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Request is not available for expert review",
|
||||
);
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "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
|
||||
String(request.initiatedByFieldExpertId) !== actor.sub
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"Only the field expert who created this file can lock and review it.",
|
||||
"Only the field expert who created this file can review it",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
|
||||
if (
|
||||
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)
|
||||
) {
|
||||
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",
|
||||
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
|
||||
|
||||
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||||
throw new ConflictException(
|
||||
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
|
||||
);
|
||||
}
|
||||
|
||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Lock the request (either unlocked or expired lock)
|
||||
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
|
||||
requestId,
|
||||
{
|
||||
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": {
|
||||
actorId: new Types.ObjectId(actorDetail.sub),
|
||||
actorName: actorDetail.fullName || "Unknown Expert",
|
||||
actorRole: "expert",
|
||||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||||
"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 (!updateResult) {
|
||||
throw new InternalServerErrorException("Failed to lock the request");
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorDetail.sub),
|
||||
expertId: String(actor.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}`,
|
||||
String(updated.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(updated.parties?.[1]?.person?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:checked:${actor.sub}`,
|
||||
});
|
||||
|
||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||
const phones = (request.parties || [])
|
||||
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);
|
||||
.filter(
|
||||
(p: unknown): p is string => typeof p === "string" && p.length > 0,
|
||||
);
|
||||
const expertLastName =
|
||||
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||
for (const phone of phones) {
|
||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||
{
|
||||
receptor: phone,
|
||||
fileKind: "blame",
|
||||
publicId: request.publicId,
|
||||
publicId: updated.publicId,
|
||||
expertLastName,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { _id: requestId, lock: true };
|
||||
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) {
|
||||
if (error instanceof ConflictException) {
|
||||
const body = error.getResponse();
|
||||
throw new BadRequestException(body);
|
||||
}
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
"lockRequestV2 failed",
|
||||
@@ -1347,7 +1549,9 @@ export class ExpertBlameService {
|
||||
parties: partyResendRequests.map((p) => ({
|
||||
partyId: String(p.partyId),
|
||||
requestedItems: p.requestedItems as ResendItemType[],
|
||||
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
|
||||
requestedItemsUi: buildResendItemsWithUi(
|
||||
p.requestedItems as string[],
|
||||
),
|
||||
description: p.description || undefined,
|
||||
})),
|
||||
};
|
||||
@@ -1359,7 +1563,9 @@ export class ExpertBlameService {
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
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),
|
||||
);
|
||||
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(
|
||||
requestId: string,
|
||||
actorId: string,
|
||||
) {
|
||||
async submitInPerson(requestId: string, actorId: string) {
|
||||
try {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
@@ -1655,7 +1860,6 @@ export class ExpertBlameService {
|
||||
requestId: String(request._id),
|
||||
status: CaseStatus.WAITING_FOR_SIGNATURES,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
@@ -1664,7 +1868,9 @@ export class ExpertBlameService {
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} 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 { RolesGuard } from "src/auth/guards/role.guard";
|
||||
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")
|
||||
@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" })
|
||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
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 { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
|
||||
|
||||
export class ClaimDetailV2ResponseDto {
|
||||
@ApiProperty()
|
||||
@@ -21,7 +21,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
@ApiPropertyOptional()
|
||||
nextStep?: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether claim is locked' })
|
||||
@ApiProperty({ description: "Whether claim is locked" })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -47,64 +47,70 @@ export class ClaimDetailV2ResponseDto {
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Blame file type from linked blame case',
|
||||
description: "Blame file type from linked blame case",
|
||||
enum: BlameRequestType,
|
||||
})
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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 },
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
|
||||
@ApiPropertyOptional({ description: "Blame request ID", example: "507f..." })
|
||||
blameRequestId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
|
||||
@ApiPropertyOptional({
|
||||
description: "Blame request number",
|
||||
example: "BL12345",
|
||||
})
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Selected outer damaged parts (id, name, side, label_fa)',
|
||||
description: "Selected outer damaged parts (id, name, side, label_fa)",
|
||||
type: [DamageSelectedPartV2BodyDto],
|
||||
})
|
||||
selectedParts?: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
|
||||
@ApiPropertyOptional({ description: "Selected other damaged parts" })
|
||||
otherParts?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
||||
requiredDocuments?: Record<string, {
|
||||
@ApiPropertyOptional({ description: "Required documents status" })
|
||||
requiredDocuments?: Record<
|
||||
string,
|
||||
{
|
||||
uploaded: boolean;
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
@ApiPropertyOptional({ description: "Car angles captured" })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)',
|
||||
type: 'array',
|
||||
"Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)",
|
||||
type: "array",
|
||||
items: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: 'number' },
|
||||
index: { type: "number" },
|
||||
partId: {
|
||||
type: 'number',
|
||||
type: "number",
|
||||
nullable: true,
|
||||
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 },
|
||||
name: { type: 'string' },
|
||||
side: { type: 'string' },
|
||||
label_fa: { type: 'string' },
|
||||
captured: { type: 'boolean' },
|
||||
url: { type: 'string' },
|
||||
id: { type: "number", nullable: true },
|
||||
name: { type: "string" },
|
||||
side: { type: "string" },
|
||||
label_fa: { type: "string" },
|
||||
captured: { type: "boolean" },
|
||||
url: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -121,24 +127,25 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Slice of `claim.evaluation` exposed to the damage expert. ' +
|
||||
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' +
|
||||
'awaiting factor validation. `ownerInsurerApproval` and ' +
|
||||
'`ownerPricedPartsApproval` are returned whenever they exist on the ' +
|
||||
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' +
|
||||
'front-end can render the user signature directly. Likewise, ' +
|
||||
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' +
|
||||
'expose `signLink` when a user comment signature is present.',
|
||||
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
||||
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
|
||||
"awaiting factor validation. `ownerInsurerApproval` and " +
|
||||
"`ownerPricedPartsApproval` are returned whenever they exist on the " +
|
||||
"claim — each carries `signLink` (resolved from `signDetailId`) so the " +
|
||||
"front-end can render the user signature directly. Likewise, " +
|
||||
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
|
||||
"expose `signLink` when a user comment signature is present.",
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
damageExpertResend: unknown;
|
||||
ownerInsurerApproval?: {
|
||||
agree: boolean;
|
||||
branchId?: string;
|
||||
@@ -166,7 +173,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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?: {
|
||||
objectionParts?: Array<Record<string, unknown>>;
|
||||
@@ -176,7 +183,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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?: {
|
||||
id: string;
|
||||
@@ -187,18 +194,19 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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>;
|
||||
|
||||
@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?: {
|
||||
sheba?: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -15,6 +15,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
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 { ListQueryV2Dto } from "src/common/dto/list-query-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")
|
||||
@ApiOperation({
|
||||
summary: "Lock a claim request for review",
|
||||
@@ -168,7 +192,7 @@ export class ExpertClaimV2Controller {
|
||||
"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" +
|
||||
"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" })
|
||||
async lockClaimRequestV2(
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
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 { 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 { 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: {
|
||||
resendDescription?: string;
|
||||
resendDocuments?: unknown[];
|
||||
@@ -34,18 +54,161 @@ export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
|
||||
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||
/** Capture / finalize lookup keys for expert-requested damaged parts. */
|
||||
export function normalizeResendPartKeys(
|
||||
parts: unknown[] | undefined,
|
||||
carType?: ClaimVehicleTypeV2,
|
||||
selectedParts?: unknown,
|
||||
): string[] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
selectedParts,
|
||||
carType,
|
||||
);
|
||||
const keys: string[] = [];
|
||||
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());
|
||||
else if (p && typeof p === "object" && "key" in (p as object)) {
|
||||
const k = String((p as { key?: string }).key || "").trim();
|
||||
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 {
|
||||
@@ -62,9 +225,10 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo
|
||||
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
|
||||
const data = claim?.media?.damagedParts;
|
||||
if (!data) return false;
|
||||
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const norm = normalizeDamageSelectedParts(
|
||||
claim?.damage?.selectedParts,
|
||||
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||||
carType,
|
||||
claim?.damage?.selectedOuterParts,
|
||||
);
|
||||
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
|
||||
@@ -82,8 +246,13 @@ export function canFinalizeExpertResend(claim: any): boolean {
|
||||
const r = claim.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
|
||||
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
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) {
|
||||
return String(r.resendDescription || "").trim() !== "";
|
||||
@@ -113,6 +282,13 @@ export function documentKeyAllowedForExpertResend(
|
||||
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import * as jMoment from "jalali-moment";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37']
|
||||
*/
|
||||
export function toJalaliDateAndTime(
|
||||
input: string | Date,
|
||||
): [string, string] {
|
||||
export function toJalaliDateAndTime(input: string | Date): [string, string] {
|
||||
const d = new Date(input);
|
||||
const dateStr = toJalaliDate(d);
|
||||
const timeStr = toJalaliTime(d);
|
||||
@@ -32,8 +28,7 @@ export function jalaliToGregorianDate(
|
||||
): string | null {
|
||||
if (input === null || input === undefined) return null;
|
||||
|
||||
const raw =
|
||||
typeof input === "number" ? String(input) : String(input).trim();
|
||||
const raw = typeof input === "number" ? String(input) : String(input).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let year = 0;
|
||||
@@ -58,20 +53,16 @@ export function jalaliToGregorianDate(
|
||||
|
||||
if (!year || !month || !day) return null;
|
||||
|
||||
// If the year already looks Gregorian (>= 1900), assume the caller already
|
||||
// sent a Gregorian date and just normalise the formatting.
|
||||
// If the year already looks Gregorian (>= 1900), just normalise formatting.
|
||||
if (year >= 1900) {
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
const m = jMoment(`${year}-${mm}-${dd}`, "YYYY-MM-DD");
|
||||
return m.isValid() ? m.format("YYYY-MM-DD") : null;
|
||||
const d = new Date(`${year}-${mm}-${dd}`);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const mm = String(month).padStart(2, "0");
|
||||
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");
|
||||
return jalaliPartsToGregorian(year, month, day);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +86,92 @@ export function toJalaliTime(d: Date): string {
|
||||
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> = {
|
||||
"۰": "0",
|
||||
"۱": "1",
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
|
||||
|
||||
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
|
||||
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
|
||||
CaseStatus.OPEN,
|
||||
CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||
// CaseStatus.OPEN,
|
||||
// CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
77
src/helpers/expert-workflow-review-assignee.ts
Normal file
77
src/helpers/expert-workflow-review-assignee.ts
Normal 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);
|
||||
}
|
||||
46
src/helpers/iran-mobile.ts
Normal file
46
src/helpers/iran-mobile.ts
Normal 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 }]),
|
||||
};
|
||||
}
|
||||
@@ -301,6 +301,19 @@ export function normalizeDamageSelectedParts(
|
||||
}
|
||||
if (typeof el === "object") {
|
||||
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()) {
|
||||
let name = o.name.trim();
|
||||
const inner = splitCatalogKeyToNameAndSide(name);
|
||||
|
||||
43
src/helpers/user-otp-expiry.ts
Normal file
43
src/helpers/user-otp-expiry.ts
Normal 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;
|
||||
}
|
||||
@@ -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`)));
|
||||
}
|
||||
}
|
||||
64
src/main.ts
64
src/main.ts
@@ -1,46 +1,78 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import * as basicAuth from "express-basic-auth";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: true,
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS",
|
||||
allowedHeaders: "Content-Type, Authorization",
|
||||
});
|
||||
|
||||
app.use(
|
||||
["/docs", "/docs-json", "/swagger"],
|
||||
basicAuth({
|
||||
challenge: true,
|
||||
users: {
|
||||
[process.env.SWAGGER_USER]: process.env.SWAGGER_PASSWORD,
|
||||
},
|
||||
}),
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
const isDevelopment = configService.get<string>("NODE_ENV") === "development";
|
||||
|
||||
if (isDevelopment) {
|
||||
app.use("/docs", (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
const challenge = () => {
|
||||
res.setHeader("WWW-Authenticate", 'Basic realm="Swagger"');
|
||||
res.status(401).send("Unauthorized");
|
||||
};
|
||||
|
||||
if (!authHeader?.startsWith("Basic ")) {
|
||||
return challenge();
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = authHeader.split(" ")[1];
|
||||
const [username, ...rest] = Buffer.from(base64, "base64")
|
||||
.toString("utf8")
|
||||
.split(":");
|
||||
const password = rest.join(":");
|
||||
|
||||
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.URL)
|
||||
.addServer(process.env.BASE_URL + "/api")
|
||||
.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, {
|
||||
|
||||
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,
|
||||
@@ -53,7 +85,9 @@ async function bootstrap() {
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen(process.env.PORT);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LightCondition,
|
||||
} from "src/Types&Enums/blame-request-management/accident-conditions.enum";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
export class InitialFormDto {
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@@ -161,16 +162,17 @@ export class LocationDto {
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export class CreateRequestDto {
|
||||
@ApiProperty({ type: String, required: false })
|
||||
type?: "THIRD_PARTY" | "CAR_BODY";
|
||||
}
|
||||
// export class CreateRequestDto {
|
||||
// @ApiProperty({ type: String, required: false })
|
||||
// type?: "THIRD_PARTY" | "CAR_BODY";
|
||||
// }
|
||||
|
||||
/**
|
||||
* V2 create blame request (new model `BlameRequest`).
|
||||
*/
|
||||
export class CreateBlameRequestDtoV2 {
|
||||
@ApiProperty({ enum: BlameRequestType })
|
||||
@IsEnum(BlameRequestType)
|
||||
@ApiProperty({ enum: BlameRequestType, description: "Type of blame request" })
|
||||
type: BlameRequestType;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +100,4 @@ export class BlameRequest {
|
||||
|
||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);
|
||||
BlameRequestSchema.index({ status: 1, "workflow.locked": 1 });
|
||||
@@ -48,5 +48,12 @@ export class Workflow {
|
||||
|
||||
@Prop({ type: ActorLockSchema })
|
||||
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);
|
||||
@@ -27,6 +27,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
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 { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||
import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto";
|
||||
@@ -112,33 +113,33 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary: "Create expert-initiated blame file",
|
||||
description:
|
||||
"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 })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "File created successfully",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requestId: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async createExpertInitiatedFile(
|
||||
@CurrentUser() expert: any,
|
||||
@Body() dto: CreateExpertInitiatedFileDto,
|
||||
) {
|
||||
return await this.requestManagementService.createExpertInitiatedFile(
|
||||
expert,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
// @ApiOperation({ deprecated: true })
|
||||
// @Post("create")
|
||||
// @ApiOperation({
|
||||
// summary: "Create expert-initiated blame file",
|
||||
// description:
|
||||
// "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 })
|
||||
// @ApiResponse({
|
||||
// status: 201,
|
||||
// description: "File created successfully",
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// requestId: { type: "string" },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
// async createExpertInitiatedFile(
|
||||
// @CurrentUser() expert: any,
|
||||
// @Body() dto: CreateExpertInitiatedFileDto,
|
||||
// ) {
|
||||
// return await this.requestManagementService.createExpertInitiatedFile(
|
||||
// expert,
|
||||
// dto,
|
||||
// );
|
||||
// }
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-form-third-party/:requestId")
|
||||
@@ -228,7 +229,7 @@ export class ExpertInitiatedController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024, // 20MB
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
@@ -285,7 +286,7 @@ export class ExpertInitiatedController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("voice", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
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 { RequestManagementService } from "./request-management.service";
|
||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||
@@ -378,7 +379,7 @@ export class ExpertInitiatedV2Controller {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -417,7 +418,7 @@ export class ExpertInitiatedV2Controller {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("voice", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
filename: (req, file, callback) => {
|
||||
@@ -483,7 +484,7 @@ export class ExpertInitiatedV2Controller {
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
filename: (req, file, callback) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
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 { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
|
||||
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||
@@ -75,7 +76,7 @@ export class RegistrarInitiatedController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
|
||||
@@ -94,7 +95,7 @@ export class RegistrarInitiatedController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("voice", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
|
||||
@@ -127,7 +128,7 @@ export class RegistrarInitiatedController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`),
|
||||
|
||||
@@ -32,11 +32,11 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
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 {
|
||||
CarBodyFormDto,
|
||||
CarBodySecondForm,
|
||||
CreateRequestDto,
|
||||
DescriptionDto,
|
||||
InitialFormDto,
|
||||
LocationDto,
|
||||
@@ -53,18 +53,18 @@ export class RequestManagementController {
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post()
|
||||
@UseGuards(GlobalGuard)
|
||||
async createRequest(
|
||||
@CurrentUser() user,
|
||||
@Body() createRequestDto: CreateRequestDto,
|
||||
) {
|
||||
return await this.requestManagementService.createRequest(
|
||||
user,
|
||||
createRequestDto.type,
|
||||
);
|
||||
}
|
||||
// @ApiOperation({ deprecated: true })
|
||||
// @Post()
|
||||
// @UseGuards(GlobalGuard)
|
||||
// async createRequest(
|
||||
// @CurrentUser() user,
|
||||
// @Body() createRequestDto: CreateRequestDto,
|
||||
// ) {
|
||||
// return await this.requestManagementService.createRequest(
|
||||
// user,
|
||||
// createRequestDto.type,
|
||||
// );
|
||||
// }
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/initial-form/:requestId")
|
||||
@@ -99,7 +99,7 @@ export class RequestManagementController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
@@ -217,7 +217,7 @@ export class RequestManagementController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
@@ -326,7 +326,7 @@ export class RequestManagementController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
@@ -381,7 +381,7 @@ export class RequestManagementController {
|
||||
{ name: "voice", maxCount: 1 },
|
||||
],
|
||||
{
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/blame-resend-docs",
|
||||
filename: (req, file, callback) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,10 @@ import {
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileFieldsInterceptor, FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
FileFieldsInterceptor,
|
||||
FileInterceptor,
|
||||
} from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { Request } from "express";
|
||||
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 { CurrentUser } from "src/decorators/user.decorator";
|
||||
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 { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import {
|
||||
@@ -113,7 +117,11 @@ export class RequestManagementV2Controller {
|
||||
@Body() body: BlameConfessionDtoV2,
|
||||
@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. */
|
||||
@@ -126,7 +134,11 @@ export class RequestManagementV2Controller {
|
||||
@Body() body: CarBodyFormDto,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
return this.requestManagementService.carBodyAccidentTypeFormV2(requestId, body, user);
|
||||
return this.requestManagementService.carBodyAccidentTypeFormV2(
|
||||
requestId,
|
||||
body,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
@@ -144,7 +156,7 @@ export class RequestManagementV2Controller {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
@@ -194,7 +206,11 @@ export class RequestManagementV2Controller {
|
||||
@Body() body: LocationDto,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
return this.requestManagementService.addDetailLocationV2(requestId, body, user);
|
||||
return this.requestManagementService.addDetailLocationV2(
|
||||
requestId,
|
||||
body,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
@@ -212,7 +228,7 @@ export class RequestManagementV2Controller {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
@@ -268,7 +284,11 @@ export class RequestManagementV2Controller {
|
||||
@Body() body: DescriptionDto,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
return this.requestManagementService.addDescriptionV2(requestId, body, user);
|
||||
return this.requestManagementService.addDescriptionV2(
|
||||
requestId,
|
||||
body,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-second-party/:phoneNumber/:requestId/:frontendRoute")
|
||||
@@ -332,7 +352,7 @@ export class RequestManagementV2Controller {
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB
|
||||
fileSize: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
@@ -354,9 +374,8 @@ export class RequestManagementV2Controller {
|
||||
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||
|
||||
// Convert string "true"/"false" to boolean
|
||||
const acceptDecision = typeof isAccept === "string"
|
||||
? isAccept === "true"
|
||||
: Boolean(isAccept);
|
||||
const acceptDecision =
|
||||
typeof isAccept === "string" ? isAccept === "true" : Boolean(isAccept);
|
||||
|
||||
return this.requestManagementService.userSignDecisionV2(
|
||||
requestId,
|
||||
@@ -404,17 +423,15 @@ export class RequestManagementV2Controller {
|
||||
format: "binary",
|
||||
description: "Voice recording file (if requested)",
|
||||
},
|
||||
video: {
|
||||
carPlate: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "Video file (if requested)",
|
||||
description: "car plate",
|
||||
},
|
||||
chassisNumber: {
|
||||
type: "string",
|
||||
description: "chassis number شماره شاسی",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description:
|
||||
"Text description when expert requested ResendItemType.description",
|
||||
},
|
||||
textDescription: {
|
||||
type: "string",
|
||||
description:
|
||||
"Alias for `description` (same semantics); either field may be used.",
|
||||
@@ -430,7 +447,8 @@ export class RequestManagementV2Controller {
|
||||
{ name: "nationalCertificate", maxCount: 1 },
|
||||
{ name: "carGreenCard", maxCount: 1 },
|
||||
{ name: "voice", maxCount: 1 },
|
||||
{ name: "video", maxCount: 1 },
|
||||
{ name: "carPlate", maxCount: 1 },
|
||||
{ name: "chassisNumber", maxCount: 1 },
|
||||
],
|
||||
{
|
||||
storage: diskStorage({
|
||||
@@ -459,7 +477,8 @@ export class RequestManagementV2Controller {
|
||||
nationalCertificate?: Express.Multer.File[];
|
||||
carGreenCard?: Express.Multer.File[];
|
||||
voice?: Express.Multer.File[];
|
||||
video?: Express.Multer.File[];
|
||||
carPlate?: Express.Multer.File[];
|
||||
chassisNumber?: Express.Multer.File[];
|
||||
},
|
||||
@Body("description") description?: string,
|
||||
@Body("textDescription") textDescription?: string,
|
||||
@@ -473,7 +492,8 @@ export class RequestManagementV2Controller {
|
||||
nationalCertificate: "image",
|
||||
carGreenCard: "image",
|
||||
voice: "voice",
|
||||
video: "video",
|
||||
carPlate: "carPlate",
|
||||
chassisNumber: "chassisNumber",
|
||||
});
|
||||
|
||||
const descCandidates = [description, textDescription]
|
||||
@@ -489,4 +509,3 @@ export class RequestManagementV2Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
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 { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@Injectable()
|
||||
export class SandHubService {
|
||||
@@ -123,8 +123,7 @@ export class SandHubService {
|
||||
const u = (url || "").toLowerCase();
|
||||
if (u.includes("personal-inquiry")) {
|
||||
const p = payload as { nationalCode?: string } | undefined;
|
||||
const nin =
|
||||
typeof p?.nationalCode === "string" ? p.nationalCode : "-";
|
||||
const nin = typeof p?.nationalCode === "string" ? p.nationalCode : "-";
|
||||
return {
|
||||
message: "success",
|
||||
data: {
|
||||
@@ -182,12 +181,11 @@ export class SandHubService {
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.post(
|
||||
process.env.SANDHUB_URL_LOGIN,
|
||||
{
|
||||
email: process.env.SANDHUB_USERNAME,
|
||||
password: process.env.SANDHUB_PASSWORD,
|
||||
},
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(process.env.SANHUB_URL_LOGIN, {
|
||||
email: process.env.SANHUB_USERNAME,
|
||||
password: process.env.SANHUB_PASSWORD,
|
||||
}),
|
||||
);
|
||||
|
||||
const token = response.data.accessToken;
|
||||
@@ -237,7 +235,8 @@ export class SandHubService {
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.httpService.axiosRef.post(
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${baseUrl}/user/login`,
|
||||
{ email, password },
|
||||
{
|
||||
@@ -247,6 +246,7 @@ export class SandHubService {
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const token = response.data?.accessToken;
|
||||
@@ -260,7 +260,10 @@ export class SandHubService {
|
||||
|
||||
return this.tejaratAccessToken;
|
||||
} 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.tejaratTokenExpiry = null;
|
||||
throw new UnauthorizedException("Tejarat inquiry authentication failed");
|
||||
@@ -278,14 +281,16 @@ export class SandHubService {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const token = await this.getTejaratAccessToken();
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (!response?.data) throw new Error("EMPTY_RESPONSE");
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
@@ -350,6 +355,58 @@ export class SandHubService {
|
||||
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) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
|
||||
@@ -361,14 +418,16 @@ export class SandHubService {
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await axios.post(url, payload, {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 50000,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (!response?.data) throw new Error("EMPTY_RESPONSE");
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
@@ -397,22 +456,32 @@ export class SandHubService {
|
||||
|
||||
// Vehicle information
|
||||
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,
|
||||
|
||||
// Financial coverage
|
||||
FinancialCvrCptl: newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
|
||||
FinancialCvrCptl:
|
||||
newResponse.financeCoverage || newResponse.FinancialCvrCptl || "0",
|
||||
|
||||
// Dates
|
||||
IssueDate: newResponse.persianStartDate || newResponse.IssueDate,
|
||||
EndDate: newResponse.persianEndDate || newResponse.EndDate,
|
||||
|
||||
// Insurance details
|
||||
LastCompanyDocumentNumber: newResponse.lastCompanyInsuranceNumber || newResponse.LastCompanyDocumentNumber || newResponse.insuranceNumber,
|
||||
LastCompanyDocumentNumber:
|
||||
newResponse.lastCompanyInsuranceNumber ||
|
||||
newResponse.LastCompanyDocumentNumber ||
|
||||
newResponse.insuranceNumber,
|
||||
|
||||
// Technical details
|
||||
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,
|
||||
VinNumberField: newResponse.vin || newResponse.VinNumberField,
|
||||
ChassisNumberField: newResponse.ChassisNumberField || newResponse.shsNam,
|
||||
@@ -422,7 +491,8 @@ export class SandHubService {
|
||||
// Discount information
|
||||
DisFnYrPrcnt: newResponse.DisFnYrPrcnt || newResponse.disFnYrPrcnt || "0",
|
||||
DisLfYrPrcnt: newResponse.DisLfYrPrcnt || newResponse.disLfYrPrcnt || "0",
|
||||
DisPrsnYrPrcnt: newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0",
|
||||
DisPrsnYrPrcnt:
|
||||
newResponse.DisPrsnYrPrcnt || newResponse.disPrsnYrPrcnt || "0",
|
||||
DisFnYrNum: newResponse.DisFnYrNum || newResponse.disFnYrNum,
|
||||
DisLfYrNum: newResponse.DisLfYrNum || newResponse.disLfYrNum,
|
||||
DisPrsnYrNum: newResponse.DisPrsnYrNum || newResponse.disPrsnYrNum,
|
||||
@@ -433,10 +503,13 @@ export class SandHubService {
|
||||
CarGroupCode: newResponse.CarGroupCode || newResponse.carGrpCod,
|
||||
|
||||
// Policy information
|
||||
ThirdPolicyCode: newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode,
|
||||
ThirdPolicyCode:
|
||||
newResponse.ThirdPolicyCode || newResponse.ThirdPolicyCode,
|
||||
|
||||
// Endorsement data
|
||||
EdrsJson: newResponse.EdrsJson || (newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined),
|
||||
EdrsJson:
|
||||
newResponse.EdrsJson ||
|
||||
(newResponse.edrSes ? JSON.stringify(newResponse.edrSes) : undefined),
|
||||
|
||||
// Insurance fullname
|
||||
InsuranceFullName: newResponse.InsuranceFullName || newResponse.fullname,
|
||||
@@ -455,7 +528,7 @@ export class SandHubService {
|
||||
rightTwoDigits: String(userDetail.plate.ir),
|
||||
nationalCode: userDetail.nationalCodeOfInsurer,
|
||||
};
|
||||
const base = process.env.SANDHUB_BASE_URL ?? "";
|
||||
const base = process.env.SANHUB_BASE_URL ?? "";
|
||||
const requestUrl = `${base}/block-inquiry-tejarat`;
|
||||
|
||||
let response: any;
|
||||
@@ -486,12 +559,9 @@ export class SandHubService {
|
||||
* 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.
|
||||
*/
|
||||
async getPersonalInquiry(
|
||||
nationalCode: string,
|
||||
birthDate: number | string,
|
||||
) {
|
||||
async getPersonalInquiry(nationalCode: string, birthDate: number | string) {
|
||||
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);
|
||||
if (!gregorianBirthdate) {
|
||||
@@ -531,7 +601,7 @@ export class SandHubService {
|
||||
nationalCode: 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 = {
|
||||
driverLicenseNumber,
|
||||
nationalCode,
|
||||
@@ -570,7 +640,7 @@ export class SandHubService {
|
||||
|
||||
async getCarOwnershipInfo(plate: any, nationalCode: string) {
|
||||
try {
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/ownership`;
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/ownership`;
|
||||
const requestPayload = {
|
||||
Plk1: String(plate.leftDigits),
|
||||
Plk2: String(plate.centerAlphabet),
|
||||
@@ -606,7 +676,7 @@ export class SandHubService {
|
||||
|
||||
async getShebaValidation(nationalId: string, shebaId: string) {
|
||||
try {
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba/sheba-tejaratno`;
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/sheba/sheba-tejaratno`;
|
||||
const requestPayload = {
|
||||
AccountOwnerType: "1",
|
||||
NationalId: nationalId,
|
||||
|
||||
102
src/sms-orchestration/provider/kavenegar-sms.gateway.ts
Normal file
102
src/sms-orchestration/provider/kavenegar-sms.gateway.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
45
src/sms-orchestration/provider/kavenegar.service.ts
Normal file
45
src/sms-orchestration/provider/kavenegar.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
107
src/sms-orchestration/provider/parsian-sms.gateway.ts
Normal file
107
src/sms-orchestration/provider/parsian-sms.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/sms-orchestration/provider/parsian-template-messages.ts
Normal file
65
src/sms-orchestration/provider/parsian-template-messages.ts
Normal 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");
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { KavenegarModule } from "@fraybabak/kavenegar_nest";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
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";
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
KavenegarModule.forRoot({
|
||||
apikey: process.env.SMS_API_KEY || "",
|
||||
}),
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [
|
||||
KavenegarService,
|
||||
KavenegarSmsGateway,
|
||||
ParsianSmsGateway,
|
||||
SmsGatewayService,
|
||||
],
|
||||
providers: [SmsGatewayService],
|
||||
exports: [SmsGatewayService],
|
||||
})
|
||||
export class SmsGatewayModule {}
|
||||
|
||||
@@ -1,112 +1,33 @@
|
||||
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
isKavenegarSuccess,
|
||||
KavenegarNormalized,
|
||||
normalizeKavenegarBody,
|
||||
safeJsonStringify,
|
||||
unwrapKavenegarTransportError,
|
||||
} from "./kavenegar-response";
|
||||
import {
|
||||
SmsProviderException,
|
||||
SmsTransportException,
|
||||
} from "./sms-provider.exception";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
|
||||
import { ParsianSmsGateway } from "./parsian-sms.gateway";
|
||||
import { getSmsProvider } from "./sms-provider.config";
|
||||
import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
|
||||
|
||||
export interface SendMessage {
|
||||
message: string;
|
||||
receptor: string;
|
||||
}
|
||||
|
||||
export interface VerifyLookUpMessage {
|
||||
template: string;
|
||||
token: string;
|
||||
token2?: string;
|
||||
token3?: string;
|
||||
token10?: string;
|
||||
receptor: string;
|
||||
}
|
||||
export type { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
|
||||
|
||||
@Injectable()
|
||||
export class SmsGatewayService {
|
||||
export class SmsGatewayService implements OnModuleInit {
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
return this.activeGateway().sendMessage(data);
|
||||
}
|
||||
|
||||
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||
try {
|
||||
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,
|
||||
);
|
||||
}
|
||||
return this.activeGateway().verifyLookUp(data);
|
||||
}
|
||||
|
||||
private logProviderRejection(
|
||||
op: "send" | "verifyLookUp",
|
||||
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}`,
|
||||
);
|
||||
private activeGateway(): KavenegarSmsGateway | ParsianSmsGateway {
|
||||
return getSmsProvider() === "parsian" ? this.parsian : this.kavenegar;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/sms-orchestration/provider/sms-gateway.types.ts
Normal file
13
src/sms-orchestration/provider/sms-gateway.types.ts
Normal 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;
|
||||
}
|
||||
18
src/sms-orchestration/provider/sms-provider.config.ts
Normal file
18
src/sms-orchestration/provider/sms-provider.config.ts
Normal 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";
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
|
||||
import { SmsGatewayModule } from "./provider/sms-gateway.module";
|
||||
import { OtpGeneratorService } from "./otp-generator.service";
|
||||
import { SmsOrchestrationService } from "./sms-orchestration.service";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
SmsGatewayModule,
|
||||
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
|
||||
],
|
||||
|
||||
21
src/types/svg-captcha.d.ts
vendored
Normal file
21
src/types/svg-captcha.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -72,7 +72,6 @@ export class ExpertModel {
|
||||
@Prop({ type: "string" })
|
||||
otp: string;
|
||||
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +1,94 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Cron, CronExpression } from "@nestjs/schedule";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
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 { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||||
|
||||
@Injectable()
|
||||
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 }) {
|
||||
await this.requestDb.findAndUpdate(
|
||||
{ _id: params.requestId },
|
||||
{ $set: { autoCloseTriggerTime: params.runAt } },
|
||||
);
|
||||
this.logger.log(
|
||||
`Scheduled auto-close for ${params.requestId} at ${params.runAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Runs every hour
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async handleAutoClose() {
|
||||
const now = new Date();
|
||||
const threshold = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const requestsToClose = await this.requestDb.findAll({
|
||||
blameStatus: { $ne: ReqBlameStatus.CloseRequest },
|
||||
$or: [
|
||||
{ "expertSubmitReply.firstPartyComment.isAccept": true },
|
||||
{ "expertSubmitReply.secondPartyComment.isAccept": true },
|
||||
{ "expertSubmitReplyFinal.firstPartyComment.isAccept": true },
|
||||
{ "expertSubmitReplyFinal.secondPartyComment.isAccept": true },
|
||||
],
|
||||
autoCloseTriggerTime: { $lte: threshold },
|
||||
});
|
||||
|
||||
for (const request of requestsToClose) {
|
||||
const reply = request.expertSubmitReplyFinal || request.expertSubmitReply;
|
||||
|
||||
const isSubmitReply = (reply: any): reply is SubmitReply =>
|
||||
reply &&
|
||||
typeof reply === "object" &&
|
||||
("firstPartyComment" in reply || "secondPartyComment" in reply);
|
||||
|
||||
if (isSubmitReply(reply)) {
|
||||
const firstAccepted = reply.firstPartyComment?.isAccept;
|
||||
const secondAccepted = reply.secondPartyComment?.isAccept;
|
||||
|
||||
if (!firstAccepted || !secondAccepted) {
|
||||
await this.requestDb.findByIdAndUpdate(request._id.toString(), {
|
||||
blameStatus: ReqBlameStatus.CloseRequest,
|
||||
});
|
||||
this.logger.log(`Auto-closed request ${request._id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
async handleBlameFileCleanup() {
|
||||
this.logger.log("Running scheduled blame file cleanup job...");
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const staleStatuses = [
|
||||
ReqBlameStatus.PendingForFirstParty,
|
||||
ReqBlameStatus.PendingForSecondParty,
|
||||
];
|
||||
|
||||
const query = {
|
||||
createdAt: { $lt: sevenDaysAgo },
|
||||
blameStatus: { $in: staleStatuses },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.requestDb.updateMany(query, {
|
||||
$set: { blameStatus: ReqBlameStatus.CloseRequest },
|
||||
});
|
||||
|
||||
if (result.modifiedCount > 0) {
|
||||
this.logger.log(
|
||||
`Successfully closed ${result.modifiedCount} stale blame files.`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log("No stale blame files found to close.");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Error during scheduled blame file cleanup:", error);
|
||||
}
|
||||
}
|
||||
// async scheduleAutoClose(params: { requestId: string; runAt: Date }) {
|
||||
// await this.requestDb.findAndUpdate(
|
||||
// { _id: params.requestId },
|
||||
// { $set: { autoCloseTriggerTime: params.runAt } },
|
||||
// );
|
||||
// this.logger.log(
|
||||
// `Scheduled auto-close for ${params.requestId} at ${params.runAt.toISOString()}`,
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Runs every hour
|
||||
// @Cron(CronExpression.EVERY_HOUR)
|
||||
// async handleAutoClose() {
|
||||
// const now = new Date();
|
||||
// const threshold = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// const requestsToClose = await this.requestDb.findAll({
|
||||
// blameStatus: { $ne: ReqBlameStatus.CloseRequest },
|
||||
// $or: [
|
||||
// { "expertSubmitReply.firstPartyComment.isAccept": true },
|
||||
// { "expertSubmitReply.secondPartyComment.isAccept": true },
|
||||
// { "expertSubmitReplyFinal.firstPartyComment.isAccept": true },
|
||||
// { "expertSubmitReplyFinal.secondPartyComment.isAccept": true },
|
||||
// ],
|
||||
// autoCloseTriggerTime: { $lte: threshold },
|
||||
// });
|
||||
|
||||
// for (const request of requestsToClose) {
|
||||
// const reply = request.expertSubmitReplyFinal || request.expertSubmitReply;
|
||||
|
||||
// const isSubmitReply = (reply: any): reply is SubmitReply =>
|
||||
// reply &&
|
||||
// typeof reply === "object" &&
|
||||
// ("firstPartyComment" in reply || "secondPartyComment" in reply);
|
||||
|
||||
// if (isSubmitReply(reply)) {
|
||||
// const firstAccepted = reply.firstPartyComment?.isAccept;
|
||||
// const secondAccepted = reply.secondPartyComment?.isAccept;
|
||||
|
||||
// if (!firstAccepted || !secondAccepted) {
|
||||
// await this.requestDb.findByIdAndUpdate(request._id.toString(), {
|
||||
// blameStatus: ReqBlameStatus.CloseRequest,
|
||||
// });
|
||||
// this.logger.log(`Auto-closed request ${request._id}`);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// @Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
// async handleBlameFileCleanup() {
|
||||
// this.logger.log("Running scheduled blame file cleanup job...");
|
||||
|
||||
// const sevenDaysAgo = new Date();
|
||||
// sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
// const staleStatuses = [
|
||||
// ReqBlameStatus.PendingForFirstParty,
|
||||
// ReqBlameStatus.PendingForSecondParty,
|
||||
// ];
|
||||
|
||||
// const query = {
|
||||
// createdAt: { $lt: sevenDaysAgo },
|
||||
// blameStatus: { $in: staleStatuses },
|
||||
// };
|
||||
|
||||
// try {
|
||||
// const result = await this.requestDb.updateMany(query, {
|
||||
// $set: { blameStatus: ReqBlameStatus.CloseRequest },
|
||||
// });
|
||||
|
||||
// if (result.modifiedCount > 0) {
|
||||
// this.logger.log(
|
||||
// `Successfully closed ${result.modifiedCount} stale blame files.`,
|
||||
// );
|
||||
// } else {
|
||||
// this.logger.log("No stale blame files found to close.");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// this.logger.error("Error during scheduled blame file cleanup:", error);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import * as crypto from "node:crypto"
|
||||
|
||||
@Injectable()
|
||||
export class HashService {
|
||||
async hash(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
hash(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) {
|
||||
return bcrypt.compare(password, hash);
|
||||
compare(password: string, hash: string): Promise<boolean> {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user