1
0
forked from Yara724/api

merge upstream

This commit is contained in:
2026-06-17 16:59:36 +03:30
23 changed files with 1467 additions and 166 deletions

View File

@@ -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",

View 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
}
]
}

View 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": "كارشناس ارزياب خسارت ثالث مالي"
}
]
}

View 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);
});

View File

@@ -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: [

View File

@@ -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",
},

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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)",

View File

@@ -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)",

View File

@@ -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);

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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 };
}
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
};
});

View File

@@ -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,

View File

@@ -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}`);
}
}

View File

@@ -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),

View File

@@ -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();
});