diff --git a/scripts/data/parsian-tehran/branches.json b/scripts/data/parsian-tehran/branches.json new file mode 100644 index 0000000..edb9711 --- /dev/null +++ b/scripts/data/parsian-tehran/branches.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/scripts/data/parsian-tehran/field-experts.json b/scripts/data/parsian-tehran/field-experts.json new file mode 100644 index 0000000..1aa5163 --- /dev/null +++ b/scripts/data/parsian-tehran/field-experts.json @@ -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": "كارشناس ارزياب خسارت ثالث مالي" + } + ] +} \ No newline at end of file diff --git a/scripts/seed-parsian-tehran.ts b/scripts/seed-parsian-tehran.ts new file mode 100644 index 0000000..661423f --- /dev/null +++ b/scripts/seed-parsian-tehran.ts @@ -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 = {}; + 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 { + 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(); + 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); +}); diff --git a/src/app.module.ts b/src/app.module.ts index 1c9c6a7..2ac0478 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/auth/auth-controllers/actor/actor.auth.controller.ts b/src/auth/auth-controllers/actor/actor.auth.controller.ts index 3141e2f..10208ac 100644 --- a/src/auth/auth-controllers/actor/actor.auth.controller.ts +++ b/src/auth/auth-controllers/actor/actor.auth.controller.ts @@ -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", }, diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 4cc4f5f..8c89c00 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -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 { - 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 }, + identifier: string, + ) { + const id = identifier.trim(); + const or: Record[] = [{ 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, { diff --git a/src/auth/dto/actor/login.actor.dto.ts b/src/auth/dto/actor/login.actor.dto.ts index 0c0c471..5fae858 100644 --- a/src/auth/dto/actor/login.actor.dto.ts +++ b/src/auth/dto/actor/login.actor.dto.ts @@ -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; diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 89e32d3..e7afe2f 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -4169,6 +4169,50 @@ export class ClaimRequestManagementService { }; } + private async assertActorCanViewClaimV2( + claim: any, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { + 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. @@ -6519,7 +6563,6 @@ export class ClaimRequestManagementService { .find( { expertInitiated: true, - creationMethod: "IN_PERSON", initiatedByFieldExpertId: new Types.ObjectId(currentUserId), }, { select: "_id", lean: true }, @@ -6532,6 +6575,7 @@ export class ClaimRequestManagementService { ...(expertBlameIds.length ? [{ blameRequestId: { $in: expertBlameIds } }] : []), + { initiatedByFieldExpertId: new Types.ObjectId(currentUserId) }, ], }, { lean: true }, @@ -6621,17 +6665,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]); @@ -6745,7 +6779,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(), diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index c1edcf8..285c4ea 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -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, diff --git a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts index 7cb8c15..21e244d 100644 --- a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts +++ b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts @@ -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)", diff --git a/src/claim-request-management/registrar-claim.mirror.controller.ts b/src/claim-request-management/registrar-claim.mirror.controller.ts index 86f1853..2d554d0 100644 --- a/src/claim-request-management/registrar-claim.mirror.controller.ts +++ b/src/claim-request-management/registrar-claim.mirror.controller.ts @@ -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)", diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 0a78afc..77d8730 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -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> { - 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 { 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 { + 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[]; + + const staleIds = new Set(); + for (const doc of visibleCases) { + const w = doc.workflow as Record | 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; + 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, ): 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); diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 2fe856f..1fc6911 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -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, diff --git a/src/request-management/expert-initiated-blame.mirror.controller.ts b/src/request-management/expert-initiated-blame.mirror.controller.ts index 6b43b49..b091e72 100644 --- a/src/request-management/expert-initiated-blame.mirror.controller.ts +++ b/src/request-management/expert-initiated-blame.mirror.controller.ts @@ -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); - } } diff --git a/src/request-management/registrar-blame.mirror.controller.ts b/src/request-management/registrar-blame.mirror.controller.ts index c605e30..f6a3150 100644 --- a/src/request-management/registrar-blame.mirror.controller.ts +++ b/src/request-management/registrar-blame.mirror.controller.ts @@ -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, - ); - } } diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 93d2b20..e742e50 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -5022,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[] = []; + 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)) || @@ -5045,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, }; }); diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index e6bf7b2..e183685 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -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, diff --git a/src/users/entities/db-service/field-expert.db.service.ts b/src/users/entities/db-service/field-expert.db.service.ts index d5992f3..f6fe200 100644 --- a/src/users/entities/db-service/field-expert.db.service.ts +++ b/src/users/entities/db-service/field-expert.db.service.ts @@ -22,6 +22,20 @@ export class FieldExpertDbService { return await this.fieldExpertModel.findOne(filter); } + async findByLoginIdentifier( + identifier: string, + ): Promise { + const id = identifier.trim(); + const or: FilterQuery[] = [ + { 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 { return await this.fieldExpertModel.findOne({ _id: new Types.ObjectId(userId), diff --git a/src/users/entities/schema/field-expert.schema.ts b/src/users/entities/schema/field-expert.schema.ts index ff7dc63..27de82f 100644 --- a/src/users/entities/schema/field-expert.schema.ts +++ b/src/users/entities/schema/field-expert.schema.ts @@ -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); \ No newline at end of file + 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(); +});