forked from Yara724/api
merge upstream
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
185
scripts/data/parsian-tehran/branches.json
Normal file
185
scripts/data/parsian-tehran/branches.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"branches": [
|
||||
{
|
||||
"code": "100100",
|
||||
"name": "واحدصدورالکترونيکي",
|
||||
"fullName": "واحدصدورالکترونيکي(100100)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "110011",
|
||||
"name": "ستاد مرکزي",
|
||||
"fullName": "ستاد مرکزي(110011)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "111130",
|
||||
"name": "شعبه ويژه ميرداماد",
|
||||
"fullName": "شعبه ويژه ميرداماد(111130)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "120021",
|
||||
"name": "سرپرستي منطقه يک كشور",
|
||||
"fullName": "سرپرستي منطقه يک كشور(120021)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
|
||||
"phoneNumber": "0218259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "130031",
|
||||
"name": "سرپرستي منطقه مركزي كشور",
|
||||
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
|
||||
"phoneNumber": "03133328257",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "140041",
|
||||
"name": "سرپرستي منطقه شمالغرب کشور",
|
||||
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
|
||||
"phoneNumber": "04133832289",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "150051",
|
||||
"name": "سرپرستي منطقه جنوب كشور",
|
||||
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
|
||||
"phoneNumber": "01738315473",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "150053",
|
||||
"name": "سرپرست منطقه جنوب شرقي کشور",
|
||||
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
|
||||
"city": "کرمان",
|
||||
"state": "کرمان",
|
||||
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
|
||||
"phoneNumber": "03432718000",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "160061",
|
||||
"name": "سرپرستي منطقه شرق كشور",
|
||||
"fullName": "سرپرستي منطقه شرق كشور(160061)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
|
||||
"phoneNumber": "05137659005",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "170071",
|
||||
"name": "سرپرستي منطقه غرب كشور",
|
||||
"fullName": "سرپرستي منطقه غرب كشور(170071)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
|
||||
"phoneNumber": "08338431017",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "180081",
|
||||
"name": "سرپرستي منطقه شمال شرق کشور",
|
||||
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
|
||||
"city": "ساري",
|
||||
"state": "مازندران",
|
||||
"address": "ساري، شعبه ساري",
|
||||
"phoneNumber": "01133207241",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "180083",
|
||||
"name": "سرپرست منطقه شمال کشوري",
|
||||
"fullName": "سرپرست منطقه شمال کشوري(180083)",
|
||||
"city": "رشت",
|
||||
"state": "گيلان",
|
||||
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
|
||||
"phoneNumber": "01333512135",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "190092",
|
||||
"name": "سرپرستي جنوب غربي کشور",
|
||||
"fullName": "سرپرستي جنوب غربي کشور(190092)",
|
||||
"city": "اهواز",
|
||||
"state": "خوزستان",
|
||||
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
|
||||
"phoneNumber": "06133743877",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210040",
|
||||
"name": "شعبه شرق تهران",
|
||||
"fullName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
|
||||
"phoneNumber": "77393783-4",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210050",
|
||||
"name": "شعبه غرب تهران",
|
||||
"fullName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران ـخيابان آزادي ( محله تيموري )، نبش خيابان شهيد داود حبيب زادگان پلاک 2 - 1458887853",
|
||||
"phoneNumber": "66021968",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210110",
|
||||
"name": "شعبه پونک",
|
||||
"fullName": "شعبه پونک(210110)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان ميرزا بابايي، نبش خيابان سردارجنگل، پارك سوارپونك",
|
||||
"phoneNumber": "44452270",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210120",
|
||||
"name": "شعبه والفجر",
|
||||
"fullName": "شعبه والفجر(210120)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
|
||||
"phoneNumber": "86051332",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210150",
|
||||
"name": "شعبه شمال شرق تهران",
|
||||
"fullName": "شعبه شمال شرق تهران(210150)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
|
||||
"phoneNumber": "26244319",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
148
scripts/data/parsian-tehran/field-experts.json
Normal file
148
scripts/data/parsian-tehran/field-experts.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"fieldExperts": [
|
||||
{
|
||||
"nationalCode": "0013480261",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "خازني",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0051967839",
|
||||
"mobile": "09121354859",
|
||||
"firstName": "حسين",
|
||||
"lastName": "جعفري",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0056888082",
|
||||
"mobile": "09122406750",
|
||||
"firstName": "قاسم",
|
||||
"lastName": "نصراللهي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0066868521",
|
||||
"mobile": "09129344240",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "گودرزي پور",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0076988961",
|
||||
"mobile": "09108357378",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "روشن دل",
|
||||
"branchCode": "210110",
|
||||
"branchName": "شعبه پونک",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0078209129",
|
||||
"mobile": "09126038117",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "شاملوفرد",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0083730397",
|
||||
"mobile": "09125759960",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "کاظمي دولت سرا",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0084130938",
|
||||
"mobile": "09392558640",
|
||||
"firstName": "رسول",
|
||||
"lastName": "کرکي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0440245151",
|
||||
"mobile": "09130606183",
|
||||
"firstName": "فرهاد",
|
||||
"lastName": "ملکي مونقي",
|
||||
"branchCode": "110011",
|
||||
"branchName": "ستاد مرکزي",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0493217789",
|
||||
"mobile": "09126966943",
|
||||
"firstName": "مصطفي",
|
||||
"lastName": "محمدزاده قورقچي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0670358118",
|
||||
"mobile": "09124421539",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "اميري",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0759153981",
|
||||
"firstName": "رضا",
|
||||
"lastName": "صالحي زاده",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "1262982308",
|
||||
"mobile": "09130121246",
|
||||
"firstName": "روح الله",
|
||||
"lastName": "سلمانيان مقدم نياسري",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "کاشان",
|
||||
"state": "اصفهان",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "3781847039",
|
||||
"firstName": "اکبر",
|
||||
"lastName": "ديني",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
}
|
||||
]
|
||||
}
|
||||
320
scripts/seed-parsian-tehran.ts
Normal file
320
scripts/seed-parsian-tehran.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
|
||||
*
|
||||
* Usage (before starting the app):
|
||||
* npm run seed:parsian-tehran
|
||||
*
|
||||
* Optional env:
|
||||
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import mongoose, { Schema, Types } from "mongoose";
|
||||
|
||||
type BranchSeed = {
|
||||
code: string;
|
||||
name: string;
|
||||
fullName?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
address: string;
|
||||
phoneNumber?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
type FieldExpertSeed = {
|
||||
nationalCode: string;
|
||||
mobile?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
branchCode: string;
|
||||
branchName?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function stripInlineComment(value: string): string {
|
||||
const hashIdx = value.indexOf(" #");
|
||||
return hashIdx === -1 ? value : value.slice(0, hashIdx).trim();
|
||||
}
|
||||
|
||||
function expandEnvValue(value: string, env: NodeJS.ProcessEnv): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => env[key] ?? "");
|
||||
}
|
||||
|
||||
function loadEnvFile() {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
if (!existsSync(envPath)) return;
|
||||
|
||||
const raw: Record<string, string> = {};
|
||||
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = stripInlineComment(trimmed.slice(idx + 1).trim());
|
||||
raw[key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (process.env[key]) continue;
|
||||
process.env[key] = stripQuotes(value);
|
||||
}
|
||||
|
||||
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
let changed = false;
|
||||
for (const key of Object.keys(process.env)) {
|
||||
const current = process.env[key];
|
||||
if (!current || !current.includes("${")) continue;
|
||||
const expanded = expandEnvValue(stripQuotes(current), process.env);
|
||||
if (expanded !== current) {
|
||||
process.env[key] = expanded;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
const value = process.env[key];
|
||||
if (value) process.env[key] = stripQuotes(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMongoUri(): string {
|
||||
const uri = process.env.MONGO_URI?.trim();
|
||||
if (!uri) {
|
||||
throw new Error("MONGO_URI is not set in .env");
|
||||
}
|
||||
if (!uri.startsWith("mongodb://") && !uri.startsWith("mongodb+srv://")) {
|
||||
throw new Error(
|
||||
`Invalid MONGO_URI after env expansion: "${uri.slice(0, 40)}..."`,
|
||||
);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
|
||||
const indexes = await collection.indexes();
|
||||
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
|
||||
if (emailIndex && !emailIndex.sparse) {
|
||||
await collection.dropIndex(emailIndex.name);
|
||||
console.log(`Dropped legacy non-sparse index: ${emailIndex.name}`);
|
||||
}
|
||||
await collection.createIndex({ email: 1 }, { unique: true, sparse: true });
|
||||
await collection.createIndex(
|
||||
{ clientKey: 1, nationalCode: 1 },
|
||||
{ unique: true, sparse: true },
|
||||
);
|
||||
}
|
||||
|
||||
function hashPassword(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);
|
||||
resolve(`${salt}:${derivedKey.toString("hex")}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema(
|
||||
{
|
||||
clientName: { type: Object, required: true },
|
||||
clientCode: { type: Number, required: true },
|
||||
useExpertMode: { type: String, required: true },
|
||||
},
|
||||
{ collection: "clients", versionKey: false },
|
||||
);
|
||||
|
||||
const BranchSchema = new Schema(
|
||||
{
|
||||
clientKey: { type: Schema.Types.ObjectId, required: true, index: true },
|
||||
name: { type: String, required: true },
|
||||
code: { type: String, required: true },
|
||||
city: { type: String, required: true },
|
||||
state: { type: String, required: true },
|
||||
address: { type: String, required: true },
|
||||
phoneNumber: { type: String },
|
||||
isActive: { type: Boolean, default: true },
|
||||
},
|
||||
{ collection: "branches", versionKey: false, timestamps: true },
|
||||
);
|
||||
|
||||
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
|
||||
|
||||
const FieldExpertSchema = new Schema(
|
||||
{
|
||||
firstName: { type: String, required: true },
|
||||
lastName: { type: String, required: true },
|
||||
email: { type: String, unique: true, sparse: true },
|
||||
username: { type: String },
|
||||
nationalCode: { type: String, index: true, sparse: true },
|
||||
clientKey: { type: Schema.Types.ObjectId, index: true },
|
||||
branchId: { type: Schema.Types.ObjectId, index: true },
|
||||
password: { type: String, required: true },
|
||||
mobile: { type: String },
|
||||
phone: { type: String },
|
||||
role: { type: String, default: "field_expert" },
|
||||
otp: { type: String, default: "" },
|
||||
},
|
||||
{ collection: "field-expert", versionKey: false, timestamps: true },
|
||||
);
|
||||
|
||||
FieldExpertSchema.index(
|
||||
{ clientKey: 1, nationalCode: 1 },
|
||||
{ unique: true, sparse: true },
|
||||
);
|
||||
|
||||
async function main() {
|
||||
loadEnvFile();
|
||||
const mongoUri = resolveMongoUri();
|
||||
|
||||
const dataDir = join(process.cwd(), "scripts/data/parsian-tehran");
|
||||
const branchesFile = JSON.parse(
|
||||
readFileSync(join(dataDir, "branches.json"), "utf8"),
|
||||
) as { clientCode: number; branches: BranchSeed[] };
|
||||
const expertsFile = JSON.parse(
|
||||
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
|
||||
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
|
||||
|
||||
const defaultPassword =
|
||||
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
|
||||
const hashedPassword = await hashPassword(defaultPassword);
|
||||
|
||||
await mongoose.connect(mongoUri, {
|
||||
tls: process.env.MONGO_TLS === "true",
|
||||
tlsAllowInvalidCertificates:
|
||||
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
|
||||
});
|
||||
const Client = mongoose.model("ClientSeedClient", ClientSchema);
|
||||
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
|
||||
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
|
||||
|
||||
await ensureFieldExpertIndexes(FieldExpert.collection);
|
||||
|
||||
const client = await Client.findOne({
|
||||
clientCode: branchesFile.clientCode,
|
||||
}).lean();
|
||||
if (!client?._id) {
|
||||
throw new Error(
|
||||
`Client with clientCode=${branchesFile.clientCode} not found in database`,
|
||||
);
|
||||
}
|
||||
const clientKey = new Types.ObjectId(String(client._id));
|
||||
|
||||
const branchIdByCode = new Map<string, Types.ObjectId>();
|
||||
let branchesCreated = 0;
|
||||
let branchesUpdated = 0;
|
||||
|
||||
for (const branch of branchesFile.branches) {
|
||||
const existing = await Branch.findOne({
|
||||
clientKey,
|
||||
code: branch.code,
|
||||
});
|
||||
const payload = {
|
||||
clientKey,
|
||||
name: branch.name,
|
||||
code: branch.code,
|
||||
city: branch.city,
|
||||
state: branch.state,
|
||||
address: branch.address,
|
||||
phoneNumber: branch.phoneNumber,
|
||||
isActive: branch.isActive ?? true,
|
||||
};
|
||||
if (existing) {
|
||||
await Branch.updateOne({ _id: existing._id }, { $set: payload });
|
||||
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
|
||||
branchesUpdated++;
|
||||
} else {
|
||||
const created = await Branch.create(payload);
|
||||
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
|
||||
branchesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
let expertsCreated = 0;
|
||||
let expertsUpdated = 0;
|
||||
let expertsSkipped = 0;
|
||||
|
||||
for (const expert of expertsFile.fieldExperts) {
|
||||
const branchId = branchIdByCode.get(expert.branchCode);
|
||||
if (!branchId) {
|
||||
console.warn(
|
||||
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
|
||||
);
|
||||
expertsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
firstName: expert.firstName,
|
||||
lastName: expert.lastName,
|
||||
username: expert.nationalCode,
|
||||
nationalCode: expert.nationalCode,
|
||||
clientKey,
|
||||
branchId,
|
||||
password: hashedPassword,
|
||||
mobile: expert.mobile,
|
||||
role: "field_expert",
|
||||
otp: "",
|
||||
};
|
||||
|
||||
const existing = await FieldExpert.findOne({
|
||||
clientKey,
|
||||
nationalCode: expert.nationalCode,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await FieldExpert.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
...payload,
|
||||
// Do not rotate password on re-seed unless explicitly desired.
|
||||
password: existing.password,
|
||||
},
|
||||
},
|
||||
);
|
||||
expertsUpdated++;
|
||||
} else {
|
||||
await FieldExpert.create(payload);
|
||||
expertsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Parsian Tehran seed completed.");
|
||||
console.log({
|
||||
clientCode: branchesFile.clientCode,
|
||||
clientKey: String(clientKey),
|
||||
branchesCreated,
|
||||
branchesUpdated,
|
||||
expertsCreated,
|
||||
expertsUpdated,
|
||||
expertsSkipped,
|
||||
defaultPassword,
|
||||
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
|
||||
});
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -23,7 +23,6 @@ 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";
|
||||
import { ExpertInitiatedModule } from "./expert-initiated/expert-initiated.module";
|
||||
import { DatabaseModule } from "./core/database/database.module";
|
||||
import { AppConfigModule } from "./core/config/config.module";
|
||||
|
||||
@@ -52,7 +51,6 @@ import { AppConfigModule } from "./core/config/config.module";
|
||||
ExpertInsurerModule,
|
||||
LookupsModule,
|
||||
WorkflowStepManagementModule,
|
||||
// ExpertInitiatedModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -150,7 +150,7 @@ export class ActorAuthController {
|
||||
@ApiOperation({
|
||||
summary: "Actor login (returns access + refresh tokens)",
|
||||
description:
|
||||
'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with the actor\'s email/`username` and password. On success the response contains the JWT pair and the resolved profile.',
|
||||
'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with password and one of `username` / `email` / `nationalCode`. On success the response contains the JWT pair and the resolved profile.',
|
||||
})
|
||||
@ApiBody({
|
||||
type: LoginActorDto,
|
||||
@@ -193,11 +193,12 @@ export class ActorAuthController {
|
||||
},
|
||||
field_expert: {
|
||||
summary: "Field expert panel",
|
||||
description: "Sample credentials for a field-expert account.",
|
||||
description:
|
||||
"Login with email+password or nationalCode+password for seeded Parsian field experts.",
|
||||
value: {
|
||||
role: "field_expert",
|
||||
username: "fieldexpert@gmail.com",
|
||||
password: "123321",
|
||||
nationalCode: "0051967839",
|
||||
password: "Parsian@724",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ActorAuthService {
|
||||
res = await this.expertDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.expertDbService.findOne({ email: username });
|
||||
else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
|
||||
break;
|
||||
case RoleEnum.DAMAGE_EXPERT:
|
||||
if (username == null && userId)
|
||||
@@ -71,14 +71,18 @@ export class ActorAuthService {
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res = await this.damageExpertDbService.findOne({ email: username });
|
||||
res = await this.findActorByLoginIdentifier(
|
||||
this.damageExpertDbService,
|
||||
username,
|
||||
);
|
||||
break;
|
||||
case RoleEnum.FIELD_EXPERT:
|
||||
if (username == null && userId)
|
||||
res = await this.fieldExpertDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
else
|
||||
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
@@ -111,13 +115,27 @@ export class ActorAuthService {
|
||||
}
|
||||
|
||||
parseActorLoginUsername(body: Record<string, unknown>): string {
|
||||
const username = body?.username ?? body?.email;
|
||||
const username = body?.username ?? body?.email ?? body?.nationalCode;
|
||||
if (typeof username !== "string" || !username.trim()) {
|
||||
throw new BadRequestException("username (email) is required");
|
||||
throw new BadRequestException(
|
||||
"username, email, or nationalCode is required",
|
||||
);
|
||||
}
|
||||
return username.trim();
|
||||
}
|
||||
|
||||
private async findActorByLoginIdentifier(
|
||||
dbService: { findOne: (filter: any) => Promise<any> },
|
||||
identifier: string,
|
||||
) {
|
||||
const id = identifier.trim();
|
||||
const or: Record<string, string>[] = [{ email: id }, { username: id }];
|
||||
if (/^\d{10}$/.test(id)) {
|
||||
or.push({ nationalCode: id });
|
||||
}
|
||||
return dbService.findOne({ $or: or });
|
||||
}
|
||||
|
||||
issueActorTokens(actor: {
|
||||
_id: Types.ObjectId;
|
||||
username?: string;
|
||||
@@ -129,12 +147,13 @@ export class ActorAuthService {
|
||||
clientKey?: Types.ObjectId | string | null;
|
||||
}) {
|
||||
const payload = {
|
||||
username: actor.username || actor.email,
|
||||
username:
|
||||
actor.username || actor.email || (actor as any).nationalCode || null,
|
||||
sub: actor._id,
|
||||
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
role: actor.role || "User",
|
||||
userType: actor.userType || "UserType",
|
||||
clientKey: actor.clientKey ?? null,
|
||||
clientKey: actor.clientKey ? String(actor.clientKey) : null,
|
||||
};
|
||||
|
||||
const access_token = this.jwtService.sign(payload, {
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
export class LoginActorDto {
|
||||
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
|
||||
role: RoleEnum[];
|
||||
|
||||
@ApiProperty({})
|
||||
username: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Actor email or username. For field experts you may also send nationalCode instead.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Alias for username when logging in with email.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "4311402422",
|
||||
description:
|
||||
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalCode?: string;
|
||||
|
||||
@ApiProperty({})
|
||||
password: string;
|
||||
|
||||
@@ -4776,6 +4776,50 @@ export class ClaimRequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
private async assertActorCanViewClaimV2(
|
||||
claim: any,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<void> {
|
||||
const ownerId = claim.owner?.userId?.toString();
|
||||
if (ownerId && ownerId === currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||||
if (claim.initiatedByFieldExpertId?.toString() === currentUserId) {
|
||||
return;
|
||||
}
|
||||
if (claim.blameRequestId) {
|
||||
const blame = await this.blameRequestDbService.findById(
|
||||
claim.blameRequestId.toString(),
|
||||
);
|
||||
if (
|
||||
blame?.expertInitiated &&
|
||||
blame.initiatedByFieldExpertId &&
|
||||
String(blame.initiatedByFieldExpertId) === currentUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actor?.role === RoleEnum.REGISTRAR && claim.blameRequestId) {
|
||||
const blame = await this.blameRequestDbService.findById(
|
||||
claim.blameRequestId.toString(),
|
||||
);
|
||||
if (
|
||||
blame?.registrarInitiated &&
|
||||
blame.initiatedByRegistrarId &&
|
||||
String(blame.initiatedByRegistrarId) === currentUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ForbiddenException("You do not have access to this claim");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective user id for claim operations.
|
||||
* For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId.
|
||||
@@ -7139,7 +7183,6 @@ export class ClaimRequestManagementService {
|
||||
.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
initiatedByFieldExpertId: new Types.ObjectId(currentUserId),
|
||||
},
|
||||
{ select: "_id", lean: true },
|
||||
@@ -7152,6 +7195,7 @@ export class ClaimRequestManagementService {
|
||||
...(expertBlameIds.length
|
||||
? [{ blameRequestId: { $in: expertBlameIds } }]
|
||||
: []),
|
||||
{ initiatedByFieldExpertId: new Types.ObjectId(currentUserId) },
|
||||
],
|
||||
},
|
||||
{ lean: true },
|
||||
@@ -7241,17 +7285,7 @@ export class ClaimRequestManagementService {
|
||||
if (!claim) {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claim,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (
|
||||
!claim.owner?.userId ||
|
||||
claim.owner.userId.toString() !== effectiveUserId
|
||||
) {
|
||||
throw new ForbiddenException("You do not have access to this claim");
|
||||
}
|
||||
await this.assertActorCanViewClaimV2(claim, currentUserId, actor);
|
||||
|
||||
const hasCapture = (data: any, key: string) =>
|
||||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||||
@@ -7365,7 +7399,9 @@ export class ClaimRequestManagementService {
|
||||
s ? s.replace(/^(.{4})(.*)(.{4})$/, "IR$1************$3") : undefined;
|
||||
const maskNationalCode = (s?: string) =>
|
||||
s ? s.replace(/^(.{2})(.*)(.{2})$/, "$1******$3") : undefined;
|
||||
const isExpertViewer = actor?.role === RoleEnum.FIELD_EXPERT;
|
||||
const isExpertViewer =
|
||||
actor?.role === RoleEnum.FIELD_EXPERT ||
|
||||
actor?.role === RoleEnum.REGISTRAR;
|
||||
const ownerData = claim.owner
|
||||
? {
|
||||
userId: claim.owner.userId?.toString(),
|
||||
|
||||
@@ -52,7 +52,7 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
@Controller("v2/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
@@ -63,7 +63,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get My Claims (V2)",
|
||||
description:
|
||||
"Claims for the current user (or field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -97,7 +97,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get Claim Details (V2)",
|
||||
description:
|
||||
"Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
|
||||
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import {
|
||||
@@ -93,19 +92,6 @@ export class ExpertInitiatedClaimMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({ summary: "[Expert mirror] List my (in-person) claims" })
|
||||
async getMyClaims(
|
||||
@CurrentUser() expert: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.claimRequestManagementService.getMyClaimsV2(
|
||||
expert.sub,
|
||||
expert,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
@@ -158,24 +144,6 @@ export class ExpertInitiatedClaimMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiOperation({ summary: "[Expert mirror] Get claim details" })
|
||||
async getClaimDetails(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() expert: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import {
|
||||
@@ -93,19 +92,6 @@ export class RegistrarClaimMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({ summary: "[Registrar mirror] List my (in-person) claims" })
|
||||
async getMyClaims(
|
||||
@CurrentUser() registrar: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
) {
|
||||
return this.claimRequestManagementService.getMyClaimsV2(
|
||||
registrar.sub,
|
||||
registrar,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
@@ -158,24 +144,6 @@ export class RegistrarClaimMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiOperation({ summary: "[Registrar mirror] Get claim details" })
|
||||
async getClaimDetails(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
|
||||
@@ -68,6 +68,7 @@ import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
@@ -243,7 +244,9 @@ export class ExpertBlameService {
|
||||
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
@@ -300,6 +303,9 @@ export class ExpertBlameService {
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
try {
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
return this.getFieldExpertBlameListV2(actor, query);
|
||||
}
|
||||
requireActorClientKey(actor);
|
||||
const expertId = actor.sub;
|
||||
|
||||
@@ -429,6 +435,90 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blame review inbox for FIELD_EXPERT — expert-initiated DISAGREEMENT files only.
|
||||
* IN_PERSON flows are AGREED and handled outside this panel; completed blames appear in expert-claim.
|
||||
*/
|
||||
private async getFieldExpertBlameListV2(
|
||||
actor: { sub: string },
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
const expertId = actor.sub;
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
const visibleCases = (await this.blameRequestDbService.find(
|
||||
{
|
||||
blameStatus: BlameStatus.DISAGREEMENT,
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertOid,
|
||||
},
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
|
||||
const staleIds = new Set<string>();
|
||||
for (const doc of visibleCases) {
|
||||
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||
if (!w?.locked) continue;
|
||||
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
|
||||
staleIds.add(String(doc._id));
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
[...staleIds].map((id) =>
|
||||
this.expireBlameCaseWorkflowLockV2IfStale(id),
|
||||
),
|
||||
);
|
||||
for (const doc of visibleCases) {
|
||||
if (!staleIds.has(String(doc._id))) continue;
|
||||
const w = doc.workflow as Record<string, unknown>;
|
||||
if (w) {
|
||||
w.locked = false;
|
||||
delete w.lockedAt;
|
||||
delete w.expiredAt;
|
||||
delete w.lockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
visibleCases,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
(doc as { requestNo?: string }).requestNo ??
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => String((doc as { status?: string }).status ?? ""),
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
};
|
||||
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
},
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
data: items,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
private mapBlameRequestToListItemV2(
|
||||
doc: Record<string, unknown>,
|
||||
): AllRequestDtoV2 {
|
||||
@@ -1344,7 +1434,9 @@ export class ExpertBlameService {
|
||||
try {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const actorId = actor.sub;
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
|
||||
@@ -1584,7 +1676,9 @@ export class ExpertBlameService {
|
||||
): Promise<{ requestId: string; status: string }> {
|
||||
try {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const actorId = actor.sub;
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
|
||||
|
||||
@@ -40,9 +40,12 @@ export class ExpertBlameV2Controller {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List blame cases for field expert (V2)",
|
||||
summary: "List blame cases for expert review (V2)",
|
||||
description:
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
|
||||
"Field experts (`field_expert`): only **DISAGREEMENT** files they initiated that need expert review (e.g. LINK disputes). " +
|
||||
"IN_PERSON expert-initiated blames are usually `AGREED` and are managed via expert-initiated / request-management APIs; after completion, use expert-claim. " +
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() actor: any,
|
||||
|
||||
@@ -95,5 +95,113 @@ export class LookupsController {
|
||||
async getAccidentCulpritType() {
|
||||
return await this.lookupsService.getAccidentCulpritType();
|
||||
}
|
||||
|
||||
@Get("accident-way")
|
||||
@ApiOkResponse({
|
||||
description: "Returns accident way options for the add-accident-fields step",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 9 },
|
||||
label: { type: "string", example: "جلو به پهلو" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getAccidentWay() {
|
||||
return await this.lookupsService.getAccidentWay();
|
||||
}
|
||||
|
||||
@Get("accident-reason")
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
"Returns accident reason options for the add-accident-fields step",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 23 },
|
||||
label: { type: "string", example: "عدم رعايت فاصله جانبی" },
|
||||
fanavaran: { type: "number", example: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getAccidentReason() {
|
||||
return await this.lookupsService.getAccidentReason();
|
||||
}
|
||||
|
||||
@Get("accident-type")
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
"Returns accident type options for the add-accident-fields step",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 3 },
|
||||
label: {
|
||||
type: "string",
|
||||
example: "برخورد یک وسیله نقلیه با وسیله نقلیه پارک شده",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getAccidentType() {
|
||||
return await this.lookupsService.getAccidentType();
|
||||
}
|
||||
|
||||
@Get("accident-fields")
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
"Returns all three accident field lookup lists in one call — drop-in replacement for the deprecated expert-blame/request/accident-fields endpoint",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accidentWay: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 9 },
|
||||
label: { type: "string", example: "جلو به پهلو" },
|
||||
},
|
||||
},
|
||||
},
|
||||
accidentReason: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 23 },
|
||||
label: { type: "string", example: "عدم رعايت فاصله جانبی" },
|
||||
fanavaran: { type: "number", example: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
accidentType: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", example: 3 },
|
||||
label: {
|
||||
type: "string",
|
||||
example: "برخورد یک وسیله نقلیه با وسیله نقلیه پارک شده",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getAccidentFields() {
|
||||
return await this.lookupsService.getAccidentFields();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
||||
|
||||
@@ -42,4 +43,43 @@ export class LookupsService {
|
||||
async getAccidentCulpritType(): Promise<any> {
|
||||
return await this.getLookup("accident-culprit-type");
|
||||
}
|
||||
|
||||
async getAccidentWay(): Promise<{ id: number; label: string }[]> {
|
||||
const raw: { id: number; persianLabel: string }[] = JSON.parse(
|
||||
await readFile("src/static/ACCIDENT_WAY.json", "utf-8"),
|
||||
);
|
||||
return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
|
||||
}
|
||||
|
||||
async getAccidentReason(): Promise<
|
||||
{ id: number; label: string; fanavaran: number }[]
|
||||
> {
|
||||
const raw: { id: number; persianLabel: string; fanavaranID: number }[] =
|
||||
JSON.parse(await readFile("src/static/ACCIDENT_REASON.json", "utf-8"));
|
||||
return raw.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.persianLabel,
|
||||
fanavaran: item.fanavaranID,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAccidentType(): Promise<{ id: number; label: string }[]> {
|
||||
const raw: { id: number; persianLabel: string }[] = JSON.parse(
|
||||
await readFile("src/static/ACCIDENT_TYPE.json", "utf-8"),
|
||||
);
|
||||
return raw.map((item) => ({ id: item.id, label: item.persianLabel }));
|
||||
}
|
||||
|
||||
async getAccidentFields(): Promise<{
|
||||
accidentWay: { id: number; label: string }[];
|
||||
accidentReason: { id: number; label: string; fanavaran: number }[];
|
||||
accidentType: { id: number; label: string }[];
|
||||
}> {
|
||||
const [accidentWay, accidentReason, accidentType] = await Promise.all([
|
||||
this.getAccidentWay(),
|
||||
this.getAccidentReason(),
|
||||
this.getAccidentType(),
|
||||
]);
|
||||
return { accidentWay, accidentReason, accidentType };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -90,14 +89,6 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "[Expert mirror] List my expert-initiated blame files",
|
||||
})
|
||||
async list(@CurrentUser() expert: any) {
|
||||
return this.requestManagementService.getMyExpertInitiatedFilesV2(expert);
|
||||
}
|
||||
|
||||
@Post("send-link/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: SendExpertInitiatedLinkV2Dto })
|
||||
@@ -420,14 +411,4 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
fields,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ summary: "[Expert mirror] Get one blame request" })
|
||||
async getOne(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() expert: any,
|
||||
) {
|
||||
return this.requestManagementService.getBlameRequestV2(requestId, expert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
@@ -86,14 +85,6 @@ export class RegistrarBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] List my registrar-initiated blame files",
|
||||
})
|
||||
async list(@CurrentUser() registrar: any) {
|
||||
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
|
||||
}
|
||||
|
||||
@Post("send-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: SendPartyOtpDto })
|
||||
@@ -402,17 +393,4 @@ export class RegistrarBlameMirrorController {
|
||||
sign,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ summary: "[Registrar mirror] Get one blame request" })
|
||||
async getOne(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.requestManagementService.getBlameRequestV2(
|
||||
requestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4679,6 +4679,24 @@ export class RequestManagementService {
|
||||
this.verifyExpertAccessForBlameV2(req, actor);
|
||||
const phone = (dto?.phoneNumber || "").trim();
|
||||
if (!phone) throw new BadRequestException("phoneNumber is required");
|
||||
|
||||
const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000);
|
||||
const recentlySentToPhone = (req.history || [])
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(
|
||||
(event: any) =>
|
||||
event?.type === "PARTY_OTP_SENT" &&
|
||||
event?.metadata?.phoneNumber === phone &&
|
||||
event?.timestamp &&
|
||||
new Date(event.timestamp) > twoMinutesAgo,
|
||||
);
|
||||
if (recentlySentToPhone) {
|
||||
throw new BadRequestException(
|
||||
`(${phone}): OTP was sent recently. Please wait 2 minutes before requesting again.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.userAuthService.sendOtpRequest(phone);
|
||||
} catch (e: any) {
|
||||
@@ -4689,6 +4707,18 @@ export class RequestManagementService {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "PARTY_OTP_SENT",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(actor.sub),
|
||||
actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
actorType:
|
||||
actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||
},
|
||||
metadata: { phoneNumber: phone },
|
||||
} as any);
|
||||
await (req as any).save();
|
||||
return {
|
||||
sent: true,
|
||||
message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`,
|
||||
@@ -4992,21 +5022,46 @@ export class RequestManagementService {
|
||||
? { "parties.person.phoneNumber": user.username }
|
||||
: null;
|
||||
|
||||
const filters = [userIdFilter, phoneFilter].filter(Boolean);
|
||||
if (filters.length === 0) {
|
||||
const orConditions: Record<string, unknown>[] = [];
|
||||
if (userIdFilter) orConditions.push(userIdFilter);
|
||||
if (phoneFilter) orConditions.push(phoneFilter);
|
||||
|
||||
if (user?.role === RoleEnum.FIELD_EXPERT && user?.sub) {
|
||||
orConditions.push({
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: new Types.ObjectId(user.sub),
|
||||
});
|
||||
} else if (user?.role === RoleEnum.REGISTRAR && user?.sub) {
|
||||
orConditions.push({
|
||||
registrarInitiated: true,
|
||||
initiatedByRegistrarId: new Types.ObjectId(user.sub),
|
||||
});
|
||||
}
|
||||
|
||||
if (orConditions.length === 0) {
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
|
||||
const requests = await this.blameRequestDbService.find(
|
||||
filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any),
|
||||
orConditions.length === 1
|
||||
? (orConditions[0] as any)
|
||||
: ({ $or: orConditions } as any),
|
||||
{
|
||||
select:
|
||||
"publicId requestNo type status blameStatus createdAt updatedAt parties",
|
||||
"publicId requestNo type status blameStatus createdAt updatedAt parties expertInitiated registrarInitiated initiatedByFieldExpertId initiatedByRegistrarId creationMethod",
|
||||
},
|
||||
);
|
||||
|
||||
const enriched = requests.map((req: any) => {
|
||||
const party = req.parties.find(
|
||||
const isInitiator =
|
||||
(user?.role === RoleEnum.FIELD_EXPERT &&
|
||||
req.expertInitiated &&
|
||||
String(req.initiatedByFieldExpertId) === String(user.sub)) ||
|
||||
(user?.role === RoleEnum.REGISTRAR &&
|
||||
req.registrarInitiated &&
|
||||
String(req.initiatedByRegistrarId) === String(user.sub));
|
||||
|
||||
const party = req.parties?.find(
|
||||
(p: any) =>
|
||||
(p?.person?.userId &&
|
||||
String(p.person.userId) === String(user.sub)) ||
|
||||
@@ -5015,11 +5070,12 @@ export class RequestManagementService {
|
||||
|
||||
const obj = req.toObject();
|
||||
|
||||
delete obj.parties; // remove parties completely
|
||||
delete obj.parties;
|
||||
|
||||
return {
|
||||
...obj,
|
||||
userSide: party?.role ?? null,
|
||||
initiatedByMe: isInitiator,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -77,11 +77,11 @@ export class RequestManagementV2Controller {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
||||
@ApiOperation({
|
||||
summary: "List my blame requests (V2)",
|
||||
description:
|
||||
"All blame files for the current user. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
"Party-owned blame files, or files initiated by the current FIELD_EXPERT / REGISTRAR. Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`.",
|
||||
})
|
||||
async getAllBlameRequestsV2(
|
||||
@CurrentUser() user: any,
|
||||
@@ -91,16 +91,16 @@ export class RequestManagementV2Controller {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one blame request by id. Allowed for the request owner (party) or the initiating field expert.
|
||||
* Get one blame request by id. Allowed for a party, or the initiating field expert / registrar.
|
||||
*/
|
||||
@Get(":requestId")
|
||||
@ApiOperation({
|
||||
summary: "Get one blame request (v2)",
|
||||
description:
|
||||
"Returns a minimal, user-safe payload: requestNo, publicId, type, status, blameStatus, workflow, parties (PII stripped), expert (with **expertName**), carBodyInsuranceDetail, plus **claimCreation** ({ hasClaim, shouldGuideToCreateClaim }). History and other internal fields are omitted.",
|
||||
"Returns a minimal payload for parties or the initiating FIELD_EXPERT / REGISTRAR: requestNo, publicId, type, status, blameStatus, workflow, parties (PII stripped for parties; full for initiator), expert, carBodyInsuranceDetail, plus **claimCreation**.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
||||
async getBlameRequestV2(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() user: any,
|
||||
|
||||
@@ -26,6 +26,10 @@ export class SandHubService {
|
||||
private tejaratAccessToken: string | null = null;
|
||||
private tejaratTokenExpiry: Date | null = null;
|
||||
|
||||
// ESG inquiry auth (used for selected tenants, e.g. CLIENT_ID=8)
|
||||
private esgAccessToken: string | null = null;
|
||||
private esgTokenExpiry: Date | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly sandHubDbService: SandHubDbService,
|
||||
@@ -45,6 +49,10 @@ export class SandHubService {
|
||||
return process.env.CLIENT_ID ?? "15";
|
||||
}
|
||||
|
||||
private shouldUseEsgInquiryProvider(): boolean {
|
||||
return String(process.env.CLIENT_ID ?? "") === "8";
|
||||
}
|
||||
|
||||
private getMockInquiryCompanyName(): string {
|
||||
return process.env.CLIENT_NAME ?? "بیمه سامان";
|
||||
}
|
||||
@@ -320,6 +328,244 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getEsgAccessToken(): Promise<string> {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
return "mock-esg-access-token";
|
||||
}
|
||||
if (this.esgAccessToken && this.esgTokenExpiry && this.esgTokenExpiry > new Date()) {
|
||||
return this.esgAccessToken;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.ESG_URL;
|
||||
const username = process.env.ESG_USERNAME;
|
||||
const password = process.env.ESG_PASSWORD;
|
||||
|
||||
if (!baseUrl || !username || !password) {
|
||||
throw new UnauthorizedException(
|
||||
"ESG credentials are not configured (ESG_URL/ESG_USERNAME/ESG_PASSWORD)",
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log("ESG token is missing or expired. Fetching a new one...");
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${baseUrl}/auth/login`,
|
||||
{ username, password },
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const token = response.data?.accessToken;
|
||||
if (!token) {
|
||||
throw new Error("No access token returned from ESG login");
|
||||
}
|
||||
|
||||
const expiresInSeconds = Number(response.data?.expiresIn ?? 900);
|
||||
const ttlMs = Number.isFinite(expiresInSeconds)
|
||||
? Math.max(30, expiresInSeconds - 60) * 1000
|
||||
: 14 * 60 * 1000;
|
||||
|
||||
this.esgAccessToken = token;
|
||||
this.esgTokenExpiry = new Date(Date.now() + ttlMs);
|
||||
return this.esgAccessToken;
|
||||
} catch (er: any) {
|
||||
this.logger.error("Failed to login to ESG inquiry:", er?.message || er);
|
||||
this.esgAccessToken = null;
|
||||
this.esgTokenExpiry = null;
|
||||
throw new UnauthorizedException("ESG inquiry authentication failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async makeEsgRequest(url: string, payload: any, maxRetries = 2) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] ESG POST skipped: ${url}`);
|
||||
return this.getDefaultMockPlateInquiryRaw();
|
||||
}
|
||||
|
||||
const INITIAL_DELAY = 500;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const token = await this.getEsgAccessToken();
|
||||
try {
|
||||
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) {
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data;
|
||||
this.logger.error(
|
||||
`ESG request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${
|
||||
status ?? "NO_STATUS"
|
||||
}`,
|
||||
data ? JSON.stringify(data) : err?.message || err,
|
||||
);
|
||||
|
||||
if (status === 401) {
|
||||
this.esgAccessToken = null;
|
||||
this.esgTokenExpiry = null;
|
||||
}
|
||||
|
||||
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
if (attempt === maxRetries - 1) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mapEsgPolicyByPlateToOldFormat(raw: any): any {
|
||||
if (!raw) return raw;
|
||||
|
||||
if (raw?.success === false) {
|
||||
return {
|
||||
Error: {
|
||||
Message:
|
||||
raw?.error?.message ||
|
||||
raw?.message ||
|
||||
"ESG policyByPlate inquiry returned an error",
|
||||
Code: raw?.error?.code || raw?.error?.providerCode || "ESG_INQUIRY_ERROR",
|
||||
ProviderMessage: raw?.error?.providerMessage,
|
||||
ProviderCode: raw?.error?.providerCode,
|
||||
TrackingCode: raw?.trackingCode,
|
||||
Conflict: raw?.error?.conflict,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = raw?.data ?? {};
|
||||
return this.mapNewApiResponseToOldFormat({
|
||||
...data,
|
||||
companyId: data.CmpCod ?? data.companyId,
|
||||
companyPersianName: data.CmpNam ?? data.companyPersianName,
|
||||
carGrpCod: data.CarGrpCod ?? data.carGrpCod,
|
||||
usgCod: data.UsgCod ?? data.usgCod,
|
||||
vehSysCod: data.VehSysCod ?? data.vehSysCod,
|
||||
mtrnum: data.MtrNum ?? data.mtrnum,
|
||||
shsNam: data.ShsNum ?? data.ShsNam ?? data.shsNam,
|
||||
vin: data.VIN ?? data.vin,
|
||||
ntnlId: data.NtnlId ?? data.ntnlId,
|
||||
fullname: data.InsNam ?? data.fullname,
|
||||
ThirdPolicyCode: data.PlcyUnqCod ?? data.ThirdPolicyCode,
|
||||
LastCompanyDocumentNumber:
|
||||
data.LastCmpDocNo ?? data.LastCompanyDocumentNumber,
|
||||
IssueDate: data.HIsuDte ?? data.IssueDate,
|
||||
StartDate: data.HBgnDte ?? data.StartDate,
|
||||
EndDate: data.HEndDte ?? data.EndDate,
|
||||
EdrsJson: data.Edrses ?? data.EdrsJson,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises Jalali birth dates to `YYYY-MM-DD` for ESG person inquiry.
|
||||
* Unlike Tejarat/SandHub personal inquiry, ESG expects Jalali — not Gregorian.
|
||||
*/
|
||||
private normalizeJalaliBirthDateForEsg(
|
||||
input: string | number | null | undefined,
|
||||
): string | null {
|
||||
if (input === null || input === undefined) return null;
|
||||
|
||||
const raw = typeof input === "number" ? String(input) : String(input).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
|
||||
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
|
||||
if (separated) {
|
||||
year = parseInt(separated[1], 10);
|
||||
month = parseInt(separated[2], 10);
|
||||
day = parseInt(separated[3], 10);
|
||||
} else {
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length === 8) {
|
||||
year = parseInt(digits.slice(0, 4), 10);
|
||||
month = parseInt(digits.slice(4, 6), 10);
|
||||
day = parseInt(digits.slice(6, 8), 10);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!year || !month || !day || year < 1300) return null;
|
||||
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
private getDefaultMockPersonInquiry(nationalCode: string): Record<string, unknown> {
|
||||
return {
|
||||
firstName: "نام",
|
||||
lastName: "خانوادگی",
|
||||
fatherName: "-",
|
||||
birthCertificateNumber: "-",
|
||||
nin: nationalCode,
|
||||
};
|
||||
}
|
||||
|
||||
private mapEsgPersonInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||
if (raw?.success === false) {
|
||||
throw new NotFoundException(
|
||||
raw?.error?.message ||
|
||||
raw?.message ||
|
||||
"Personal inquiry failed: Record not found for the given national code and birth date.",
|
||||
);
|
||||
}
|
||||
|
||||
const data = raw?.data ?? {};
|
||||
return {
|
||||
firstName: data.Name ?? data.firstName,
|
||||
lastName: data.Family ?? data.lastName,
|
||||
fatherName: data.FatherName ?? data.fatherName,
|
||||
birthCertificateNumber:
|
||||
data.Shenasnameserial ??
|
||||
data.ShenasnameNo ??
|
||||
data.birthCertificateNumber,
|
||||
nin: data.Nin ?? data.nationalCode ?? data.nin,
|
||||
fullName: data.fullName,
|
||||
birthDate: data.BirthDate ?? data.birthDate,
|
||||
gender: data.Gender,
|
||||
trackingCode: raw?.trackingCode,
|
||||
};
|
||||
}
|
||||
|
||||
private mapEsgShebaInquiryToOldFormat(raw: any): Record<string, unknown> {
|
||||
if (raw?.success === false) {
|
||||
throw new BadRequestException(
|
||||
raw?.error?.message ||
|
||||
raw?.message ||
|
||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
||||
);
|
||||
}
|
||||
|
||||
const data = raw?.data ?? {};
|
||||
return {
|
||||
ReturnValue: data.ReturnValue ?? true,
|
||||
HasError: data.HasError ?? false,
|
||||
Errors: data.Errors ?? {},
|
||||
Message: raw?.message ?? data.Message,
|
||||
trackingCode: raw?.trackingCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
||||
if (!(await this.useLiveSandHubApis())) {
|
||||
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
|
||||
@@ -377,6 +623,30 @@ export class SandHubService {
|
||||
raw: any;
|
||||
mapped: any;
|
||||
}> {
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestPayload = {
|
||||
nationalCode: String(userDetail.nationalCodeOfInsurer),
|
||||
plk1: String(userDetail.plate.leftDigits),
|
||||
plk2: String(userDetail.plate.centerAlphabet),
|
||||
plk3: String(userDetail.plate.centerDigits),
|
||||
plksrl: String(userDetail.plate.ir),
|
||||
};
|
||||
|
||||
const requestUrl = `${baseUrl}/inquiry/policyByPlate`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
const raw = live
|
||||
? await this.makeEsgRequest(requestUrl, requestPayload)
|
||||
: this.getDefaultMockPlateInquiryRaw();
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getEsgPolicyByPlateInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||
);
|
||||
}
|
||||
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
|
||||
return { raw, mapped };
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
|
||||
const requestPayload = {
|
||||
@@ -669,17 +939,43 @@ export class SandHubService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal identity check against the Tejarat hub. The upstream gateway only
|
||||
* accepts a Gregorian birthdate in `YYYY-MM-DD` form, but every caller in
|
||||
* 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.
|
||||
* Personal identity check.
|
||||
* - CLIENT_ID=8 (Parsian/ESG): Jalali birth date sent as-is to `/inquiry/person`.
|
||||
* - Other tenants: Tejarat/SandHub hub with Gregorian conversion.
|
||||
*/
|
||||
async getPersonalInquiry(nationalCode: string, birthDate: number | string) {
|
||||
try {
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const jalaliBirthDate = this.normalizeJalaliBirthDateForEsg(birthDate);
|
||||
if (!jalaliBirthDate) {
|
||||
throw new BadRequestException(
|
||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13781124 or "1378-11-24").`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestPayload = {
|
||||
nationalCode: String(nationalCode),
|
||||
birthDate: jalaliBirthDate,
|
||||
dateHasPostfix: 0,
|
||||
};
|
||||
const requestUrl = `${baseUrl}/inquiry/person`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getEsgPersonInquiry nationalCode=${nationalCode} birthDate=${jalaliBirthDate}`,
|
||||
);
|
||||
return this.getDefaultMockPersonInquiry(String(nationalCode));
|
||||
}
|
||||
|
||||
const raw = await this.makeEsgRequest(requestUrl, requestPayload);
|
||||
return this.mapEsgPersonInquiryToOldFormat(raw);
|
||||
}
|
||||
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/personal-inquiry/tejarat-no`;
|
||||
|
||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||
console.log(gregorianBirthdate);
|
||||
if (!gregorianBirthdate) {
|
||||
throw new BadRequestException(
|
||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
||||
@@ -792,6 +1088,48 @@ export class SandHubService {
|
||||
|
||||
async getShebaValidation(nationalId: string, shebaId: string) {
|
||||
try {
|
||||
if (this.shouldUseEsgInquiryProvider()) {
|
||||
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
|
||||
const requestPayload = {
|
||||
accountOwnerType: "1",
|
||||
nationalCode: String(nationalId),
|
||||
legalId: "",
|
||||
sheba: String(shebaId),
|
||||
};
|
||||
const requestUrl = `${baseUrl}/inquiry/sheba`;
|
||||
const live = await this.useLiveSandHubApis();
|
||||
|
||||
this.logger.log(
|
||||
`Validating Sheba ID via ESG for national code: ${nationalId}`,
|
||||
);
|
||||
|
||||
if (!live) {
|
||||
this.logger.debug(
|
||||
`[MOCK] getEsgShebaInquiry nationalCode=${nationalId} sheba=${shebaId}`,
|
||||
);
|
||||
return {
|
||||
ReturnValue: true,
|
||||
HasError: false,
|
||||
Message: "mock-sheba-ok",
|
||||
};
|
||||
}
|
||||
|
||||
const raw = await this.makeEsgRequest(requestUrl, requestPayload);
|
||||
const response = this.mapEsgShebaInquiryToOldFormat(raw);
|
||||
|
||||
if (response?.ReturnValue === false || response?.HasError === true) {
|
||||
this.logger.warn(
|
||||
`Sheba validation failed for national code ${nationalId}. Response:`,
|
||||
response,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Sheba ID validation failed. The provided Sheba ID does not match the national ID.",
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const requestUrl = `${process.env.SANHUB_BASE_URL}/sheba/sheba-tejaratno`;
|
||||
const requestPayload = {
|
||||
AccountOwnerType: "1",
|
||||
@@ -817,6 +1155,9 @@ export class SandHubService {
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Error in matching sheba validation: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,20 @@ export class FieldExpertDbService {
|
||||
return await this.fieldExpertModel.findOne(filter);
|
||||
}
|
||||
|
||||
async findByLoginIdentifier(
|
||||
identifier: string,
|
||||
): Promise<FieldExpertModel | null> {
|
||||
const id = identifier.trim();
|
||||
const or: FilterQuery<FieldExpertModel>[] = [
|
||||
{ email: id },
|
||||
{ username: id },
|
||||
];
|
||||
if (/^\d{10}$/.test(id)) {
|
||||
or.push({ nationalCode: id });
|
||||
}
|
||||
return await this.fieldExpertModel.findOne({ $or: or });
|
||||
}
|
||||
|
||||
async findById(userId: string): Promise<FieldExpertModel | null> {
|
||||
return await this.fieldExpertModel.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
|
||||
@@ -16,17 +16,26 @@ export class FieldExpertModel {
|
||||
@Prop({ required: true })
|
||||
lastName: string;
|
||||
|
||||
@Prop({ type: "string", unique: true })
|
||||
email: string;
|
||||
@Prop({ type: String, unique: true, sparse: true, required: false })
|
||||
email?: string;
|
||||
|
||||
@Prop({ type: "string" })
|
||||
username: string;
|
||||
@Prop({ type: String })
|
||||
username?: string;
|
||||
|
||||
@Prop({ type: String, index: true, sparse: true })
|
||||
nationalCode?: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
clientKey?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ required: true })
|
||||
password: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
mobile: string;
|
||||
@Prop({ required: false })
|
||||
mobile?: string;
|
||||
|
||||
@Prop({ required: false })
|
||||
phone?: string;
|
||||
@@ -41,4 +50,16 @@ export class FieldExpertModel {
|
||||
}
|
||||
|
||||
export const FieldExpertDbSchema =
|
||||
SchemaFactory.createForClass(FieldExpertModel);
|
||||
SchemaFactory.createForClass(FieldExpertModel);
|
||||
|
||||
FieldExpertDbSchema.index(
|
||||
{ clientKey: 1, nationalCode: 1 },
|
||||
{ unique: true, sparse: true },
|
||||
);
|
||||
|
||||
FieldExpertDbSchema.pre("save", function (next) {
|
||||
if (!this.username) {
|
||||
this.username = this.email || this.nationalCode;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user