Merge pull request 'Fixed mismatched userId on field expert for claim' (#137) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#137
This commit is contained in:
2026-06-20 11:55:34 +03:30
26 changed files with 144 additions and 414 deletions

View File

@@ -71,6 +71,7 @@ import {
} from "src/helpers/user-access-resolver"; } from "src/helpers/user-access-resolver";
import { import {
blameDamagedPartyMatchesUser, blameDamagedPartyMatchesUser,
resolveClaimOwnerFieldsFromBlame,
resolveClaimOwnerParty, resolveClaimOwnerParty,
resolveDamagedPartyUserId, resolveDamagedPartyUserId,
} from "src/helpers/blame-damaged-party"; } from "src/helpers/blame-damaged-party";
@@ -4672,7 +4673,7 @@ export class ClaimRequestManagementService {
/** /**
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame. * V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
* The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY). * Owner: damaged party `userId` (SMS / user actions), guilty party `clientId` (insurer queue).
*/ */
async createClaimFromBlameForExpertV2( async createClaimFromBlameForExpertV2(
blameRequestId: string, blameRequestId: string,
@@ -4702,29 +4703,11 @@ export class ClaimRequestManagementService {
"Only the field expert who created this blame file can create the claim.", "Only the field expert who created this blame file can create the claim.",
); );
} }
const parties = blameRequest.parties || []; const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; if (!ownerFields) {
let damagedUserId: string; throw new BadRequestException(
if (isCarBody) { "Could not resolve claim owner (damaged party) from blame",
if (parties.length < 1)
throw new BadRequestException("Blame request has no party");
const first = parties.find((p) => p.role === "FIRST");
if (!first?.person?.userId)
throw new BadRequestException("First party has no userId");
damagedUserId = String(first.person.userId);
} else {
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
? String(blameRequest.expert.decision.guiltyPartyId)
: null;
if (!guiltyPartyId)
throw new BadRequestException("Blame request has no guilty party set");
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
); );
if (!damagedParty?.person?.userId) {
throw new BadRequestException("Could not determine damaged party");
}
damagedUserId = String(damagedParty.person.userId);
} }
const existingClaim = await this.claimCaseDbService.findOne({ const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId), blameRequestId: new Types.ObjectId(blameRequestId),
@@ -4733,12 +4716,6 @@ export class ClaimRequestManagementService {
throw new ConflictException("A claim for this blame case already exists"); throw new ConflictException("A claim for this blame case already exists");
} }
const claimNo = await this.generateUniqueClaimNumber(); const claimNo = await this.generateUniqueClaimNumber();
const ownerParty = resolveClaimOwnerParty(blameRequest);
if (!ownerParty?.person?.userId) {
throw new BadRequestException(
"Could not resolve claim owner (guilty party) from blame",
);
}
const newClaim = await this.claimCaseDbService.create({ const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo, requestNo: claimNo,
publicId: blameRequest.publicId, publicId: blameRequest.publicId,
@@ -4754,15 +4731,14 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false, locked: false,
}, },
damagedPartyUserId: new Types.ObjectId(damagedUserId), damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
owner: { owner: {
userId: new Types.ObjectId(String(ownerParty.person.userId)), userId: new Types.ObjectId(ownerFields.userId),
userRole: ownerParty.role as any, userRole: ownerFields.userRole as any,
...(ownerParty.person.clientId ...(ownerFields.clientId
? { ? {
clientId: new Types.ObjectId( clientId: new Types.ObjectId(ownerFields.clientId),
String(ownerParty.person.clientId), userClientKey: ownerFields.clientId,
),
} }
: {}), : {}),
}, },
@@ -5020,26 +4996,11 @@ export class ClaimRequestManagementService {
"Only the registrar who created this blame file can create the claim.", "Only the registrar who created this blame file can create the claim.",
); );
} }
const parties = blameRequest.parties || []; const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; if (!ownerFields) {
let damagedUserId: string; throw new BadRequestException(
if (isCarBody) { "Could not resolve claim owner (damaged party) from blame",
const first = parties.find((p) => p.role === "FIRST");
if (!first?.person?.userId)
throw new BadRequestException("First party has no userId");
damagedUserId = String(first.person.userId);
} else {
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
? String(blameRequest.expert.decision.guiltyPartyId)
: null;
if (!guiltyPartyId)
throw new BadRequestException("Blame request has no guilty party set");
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
); );
if (!damagedParty?.person?.userId)
throw new BadRequestException("Could not determine damaged party");
damagedUserId = String(damagedParty.person.userId);
} }
const existingClaim = await this.claimCaseDbService.findOne({ const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId), blameRequestId: new Types.ObjectId(blameRequestId),
@@ -5047,12 +5008,6 @@ export class ClaimRequestManagementService {
if (existingClaim) if (existingClaim)
throw new ConflictException("A claim for this blame case already exists"); throw new ConflictException("A claim for this blame case already exists");
const claimNo = await this.generateUniqueClaimNumber(); const claimNo = await this.generateUniqueClaimNumber();
const ownerParty = resolveClaimOwnerParty(blameRequest);
if (!ownerParty?.person?.userId) {
throw new BadRequestException(
"Could not resolve claim owner (guilty party) from blame",
);
}
const newClaim = await this.claimCaseDbService.create({ const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo, requestNo: claimNo,
publicId: blameRequest.publicId, publicId: blameRequest.publicId,
@@ -5067,15 +5022,14 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED], completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false, locked: false,
}, },
damagedPartyUserId: new Types.ObjectId(damagedUserId), damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
owner: { owner: {
userId: new Types.ObjectId(String(ownerParty.person.userId)), userId: new Types.ObjectId(ownerFields.userId),
userRole: ownerParty.role as any, userRole: ownerFields.userRole as any,
...(ownerParty.person.clientId ...(ownerFields.clientId
? { ? {
clientId: new Types.ObjectId( clientId: new Types.ObjectId(ownerFields.clientId),
String(ownerParty.person.clientId), userClientKey: ownerFields.clientId,
),
} }
: {}), : {}),
}, },

View File

@@ -1,21 +0,0 @@
import { Body, Controller, Post } from "@nestjs/common";
import { Public } from "./decorators";
import { UserLoginDto, UserRegisterDto } from "./dtos";
import { AuthService } from "./auth.service";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post("user/register")
async register(@Body() userRegisterDto: UserRegisterDto) {
return await this.authService.registerUser(userRegisterDto);
}
@Public()
@Post("user/login")
async login(@Body() userLoginDto: UserLoginDto) {
return await this.authService.loginUser(userLoginDto);
}
}

View File

@@ -1,32 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { JwtModule } from "@nestjs/jwt";
import { StringValue } from "ms";
import { AuthGuard, RolesGuard } from "./guards";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { HashService } from "./providers";
@Module({
imports: [
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>("JWT_SECRET"),
signOptions: {
expiresIn: configService.get<StringValue>("JWT_EXPIRY"),
},
}),
}),
],
controllers: [AuthController],
providers: [
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
AuthService,
HashService,
],
})
export class AuthModule {}

View File

@@ -1,31 +0,0 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { UserRepository } from "src/features/user/repositories";
import { UserLoginDto, UserRegisterDto } from "./dtos";
@Injectable()
export class AuthService {
constructor(private readonly userRepo: UserRepository) {}
async registerUser(userRegisterDto: UserRegisterDto) {
const user = await this.userRepo.retrieveByCellPhoneNumber(
userRegisterDto.cellphoneNumber,
);
if (!user) {
// Create user + Send OTP and update database
}
// Send OTP to the cellphone number of user and update database
// TODO: Create a mock otp for once we got no otp senders or development phase
}
async loginUser(userLoginDto: UserLoginDto) {
const user = await this.userRepo.retrieveByCellPhoneNumber(
userLoginDto.cellphoneNumber,
);
if (!user) throw new BadRequestException("User does not exist");
// Generate token (access + refresh) and save inside the database, return them to the user
}
}

View File

@@ -1,8 +1,18 @@
import { ScryptOptions } from "node:crypto"; import { ScryptOptions } from "node:crypto";
export const CURRENT_ALGORITHM = "scrypt";
export const CURRENT_VERSION = 1; // 0 = legacy salt:hash ; 1 = scrypt with different salt and hash and differnet format of salt:hash!
export const KEY_LENGTH = 64; export const KEY_LENGTH = 64;
export const SCRYPT_PARAMS: ScryptOptions = { export const SCRYPT_PARAMS: ScryptOptions = {
N: 19456, // CPU/memory cost N: 19456, // CPU/memory cost
r: 8, // block size r: 8, // block size
p: 1, // parallelization p: 1, // parallelization
}; };
export const KEY_LENGTH_FOR_OTP = 32;
export const SCRYPT_PARAMS_FOR_OTP: ScryptOptions = {
N: 1024,
r: 8,
p: 1,
};

View File

@@ -1,2 +0,0 @@
export { UserRegisterDto } from "./user-register.dto";
export { UserLoginDto } from "./user-login.dto";

View File

@@ -1,10 +0,0 @@
import { IsMobilePhone, IsNumberString, IsString } from "class-validator";
export class UserLoginDto {
@IsString({ message: "Cellphone number must be string" })
@IsMobilePhone("fa-IR")
cellphoneNumber: string;
@IsNumberString()
otp: string;
}

View File

@@ -1,7 +0,0 @@
import { IsMobilePhone, IsNumberString, IsString } from "class-validator";
export class UserRegisterDto {
@IsString({ message: "Cellphone number must be string" })
@IsMobilePhone("fa-IR")
cellphoneNumber: string;
}

View File

@@ -1,68 +0,0 @@
import {
BinaryLike,
randomBytes,
scrypt,
ScryptOptions,
timingSafeEqual,
} from "node:crypto";
import { promisify } from "node:util";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { KEY_LENGTH, SCRYPT_PARAMS } from "../constants/hash.constants";
const scryptAsync = promisify<
BinaryLike,
BinaryLike,
number,
ScryptOptions,
Buffer
>(scrypt);
@Injectable()
export class HashService {
private readonly pepper: string;
constructor(private readonly configService: ConfigService) {
this.pepper = this.configService.get<string>("HASH_PEPPER");
}
private applyPepper(input: string): string {
return this.pepper ? `${input}${this.pepper}` : input;
}
private generateSalt(bytes: number = 16): string {
return randomBytes(bytes).toString("hex");
}
private async scrypt(password: string): Promise<[string, string]> {
const salt = this.generateSalt();
const pepperedInput = this.applyPepper(password);
const hashedPassword = await scryptAsync(
pepperedInput,
salt,
KEY_LENGTH,
SCRYPT_PARAMS,
);
return [salt, hashedPassword.toString("hex")];
}
private async verifyScrypt(
password: string,
salt: string,
hashedPassword: string,
): Promise<boolean> {
const pepperedInput = this.applyPepper(password);
const derived = await scryptAsync(
pepperedInput,
salt,
KEY_LENGTH,
SCRYPT_PARAMS,
);
const storedBuffer = Buffer.from(hashedPassword, "hex");
if (derived.length !== storedBuffer.length) return false;
return timingSafeEqual(derived, storedBuffer);
}
}

View File

@@ -1 +0,0 @@
export { HashService } from "./hash.provider";

View File

@@ -1 +1,2 @@
export { JwtPayload } from "./payload.types"; export { JwtPayload } from "./payload.types";
export { PasswordHash } from "./password.types";

View File

@@ -1,7 +1,6 @@
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
role: string; role: string;
clientId?: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }

View File

@@ -2,10 +2,12 @@ import {
IsBooleanString, IsBooleanString,
IsEnum, IsEnum,
IsNumber, IsNumber,
IsNumberString,
IsOptional, IsOptional,
IsPositive, IsPositive,
IsString, IsString,
IsUrl, IsUrl,
Length,
Matches, Matches,
Max, Max,
Min, Min,
@@ -80,9 +82,11 @@ export class EnvironmentVariables {
JWT_EXPIRY: StringValue; JWT_EXPIRY: StringValue;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsString({ message: "HASH_PEPPER must be string" }) @IsString({ message: "HASH_PEPPER must be string" })
@IsOptional() @IsOptional()
HASH_PEPPER?: string; HASH_PEPPER?: string;
// --------------------------------------------------------- // // --------------------------------------------------------- //
@IsOptional() @IsOptional()

View File

@@ -440,12 +440,13 @@ export class ExpertClaimService {
return fallback; return fallback;
} }
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */ /** Damaged-party mobile for claim SMS: prefer `damagedPartyUserId`, else `owner.userId`. */
private async resolveClaimOwnerPhone( private async resolveClaimOwnerPhone(
claim: any, claim: any,
): Promise<string | undefined> { ): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined; const notifyUserId = claim?.damagedPartyUserId ?? claim?.owner?.userId;
const ownerUserId = String(claim.owner.userId); if (!notifyUserId) return undefined;
const ownerUserId = String(notifyUserId);
if (claim.blameRequestId) { if (claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById( const blame = await this.blameRequestDbService.findById(
claim.blameRequestId.toString(), claim.blameRequestId.toString(),

View File

@@ -1,32 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
export class FieldExpertLoginDto {
@ApiProperty()
@IsEmail({ allow_underscores: true })
@IsString({
message: "email is not string",
context: {
errorCode: 4000,
note: "The validated email type must be string",
},
})
readonly email: string;
@ApiProperty()
@IsNotEmpty({
message: "password is empty",
context: {
errorCode: 4001,
note: "The validated password must not be empty",
},
})
@IsString({
message: "password is not string",
context: {
errorCode: 4002,
note: "The validated password type must be string",
},
})
readonly password: string;
}

View File

@@ -1,16 +0,0 @@
import { Body, Controller, Post } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { FieldExpertLoginDto } from "./dtos/login.dto";
import { FieldExpertService } from "./expert-initiated.service";
@Controller("expert-initiated")
@ApiTags("Expert Initiated Flow (Field Expert)")
@ApiBearerAuth()
export class FieldExpertController {
constructor(private readonly fieldExpertService: FieldExpertService) {}
@Post("login")
async login(@Body() fieldExpertLoginDto: FieldExpertLoginDto) {
return await this.fieldExpertService.login(fieldExpertLoginDto);
}
}

View File

@@ -1,25 +0,0 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { FieldExpert, FieldExpertSchema } from "./schemas/field-expert.schema";
import { FieldExpertController } from "./expert-initiated.controller";
import { FieldExpertService } from "./expert-initiated.service";
import { FieldExpertRepository } from "./repositories/field-expert.repository";
import { AuthModule } from "src/auth/auth.module";
import { JwtModule } from "@nestjs/jwt";
@Module({
imports: [
MongooseModule.forFeature([
{
name: FieldExpert.name,
schema: FieldExpertSchema,
},
]),
AuthModule,
JwtModule,
],
controllers: [FieldExpertController],
providers: [FieldExpertRepository, FieldExpertService],
exports: [],
})
export class ExpertInitiatedModule {}

View File

@@ -1,32 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { FieldExpertRepository } from "./repositories/field-expert.repository";
import { FieldExpertLoginDto } from "./dtos/login.dto";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@Injectable()
export class FieldExpertService {
constructor(
private readonly fieldExpertRepository: FieldExpertRepository,
private readonly authService: UserAuthService,
) {}
async login(fieldExpertLoginDto: FieldExpertLoginDto) {
const user = await this.fieldExpertRepository.retrieveByEmail(
fieldExpertLoginDto.email,
);
if (!user) throw new NotFoundException("User not found");
const validate = await this.authService.validateUser(
user.email,
user.password,
);
if (!validate)
throw new BadRequestException("Username/Password does not match");
}
}

View File

@@ -1,22 +0,0 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { FieldExpert } from "../schemas/field-expert.schema";
@Injectable()
export class FieldExpertRepository {
constructor(
@InjectModel(FieldExpert.name)
private readonly fieldExpertModel: Model<FieldExpert>,
) {}
async retrieveById(id: string): Promise<FieldExpert> {
return await this.fieldExpertModel.findById(id).exec();
}
async retrieveByEmail(email: string): Promise<FieldExpert> {
return await this.fieldExpertModel.findOne({
email,
});
}
}

View File

@@ -1,27 +0,0 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument, Types } from "mongoose";
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
@Schema({
id: true,
timestamps: true,
})
export class FieldExpert {
@Prop({ unique: true })
email: string;
@Prop({ required: true })
password: string;
@Prop({ required: true })
firstName: string;
@Prop({ required: true })
lastName: string;
@Prop({ required: true })
cellphone: string;
}
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);

View File

@@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { InjectModel } from "@nestjs/mongoose";
import { User } from "../schemas";
import { Model } from "mongoose"; import { Model } from "mongoose";
import { Otp, User } from "../schemas";
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
@@ -10,7 +10,9 @@ export class UserRepository {
private readonly model: Model<User>, private readonly model: Model<User>,
) {} ) {}
async addUser() {} async create(cellphoneNumber: string): Promise<User> {
return this.model.create({ cellphoneNumber });
}
async retrieveByCellPhoneNumber( async retrieveByCellPhoneNumber(
cellphoneNumber: string, cellphoneNumber: string,
@@ -19,4 +21,19 @@ export class UserRepository {
cellphoneNumber, cellphoneNumber,
}); });
} }
async saveOtp(cellphoneNumber: string, otp: Otp): Promise<void> {
await this.model.updateOne({ cellphoneNumber }, { $set: { otp } });
}
async clearOtp(cellphoneNumber: string): Promise<void> {
await this.model.updateOne({ cellphoneNumber }, { $unset: { otp: "" } });
}
async incrementOtpAttempts(cellphoneNumber: string): Promise<void> {
await this.model.updateOne(
{ cellphoneNumber },
{ $inc: { "otp.attempts": 1 } },
);
}
} }

View File

@@ -8,17 +8,14 @@ export class Otp {
@Prop() @Prop()
hash: string; hash: string;
@Prop()
salt: string;
@Prop() @Prop()
expiresAt: Date; expiresAt: Date;
@Prop({ default: 0 }) @Prop({ default: 0 })
attempts: number; attempts?: number;
@Prop({ default: Date.now }) @Prop({ default: Date.now })
generatedAt: Date; generatedAt?: Date;
} }
export const OtpSchema = SchemaFactory.createForClass(Otp); export const OtpSchema = SchemaFactory.createForClass(Otp);

View File

@@ -1,8 +1,17 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { UserRepository } from "./repositories"; import { UserRepository } from "./repositories";
import { User, UserSchema } from "./schemas";
@Module({ @Module({
imports: [], imports: [
MongooseModule.forFeature([
{
name: User.name,
schema: UserSchema,
},
]),
],
controllers: [], controllers: [],
providers: [UserRepository], providers: [UserRepository],
exports: [UserRepository], exports: [UserRepository],

View File

@@ -2,6 +2,7 @@ import { Types } from "mongoose";
import { partyPersonMatchesUser } from "./iran-mobile"; import { partyPersonMatchesUser } from "./iran-mobile";
import { import {
blameDamagedPartyMatchesUser, blameDamagedPartyMatchesUser,
resolveClaimOwnerFieldsFromBlame,
resolveDamagedPartyPerson, resolveDamagedPartyPerson,
resolveGuiltyPartyUserId, resolveGuiltyPartyUserId,
} from "./blame-damaged-party"; } from "./blame-damaged-party";
@@ -46,13 +47,30 @@ describe("blame damaged party helpers", () => {
).toBe(false); ).toBe(false);
}); });
it("matches damaged party by phone when userId differs (duplicate accounts)", () => { it("resolveClaimOwnerFieldsFromBlame uses damaged userId and guilty clientId", () => {
expect( const guiltyClientId = new Types.ObjectId();
partyPersonMatchesUser( const blame = {
{ userId: damagedId, phoneNumber: "9123456789" }, type: "THIRD_PARTY",
{ sub: "other-user-id", username: "09123456789" }, expert: { decision: { guiltyPartyId: guiltyId } },
[], parties: [
), {
).toBe(true); role: "FIRST",
person: {
userId: guiltyId,
phoneNumber: "09111111111",
clientId: guiltyClientId,
},
},
{
role: "SECOND",
person: { userId: damagedId, phoneNumber: "09912356917" },
},
],
};
expect(resolveClaimOwnerFieldsFromBlame(blame)).toEqual({
userId: String(damagedId),
userRole: "SECOND",
clientId: String(guiltyClientId),
});
}); });
}); });

View File

@@ -119,3 +119,45 @@ export function resolveDamagedPartyUserId(
const person = resolveDamagedPartyPerson(blame); const person = resolveDamagedPartyPerson(blame);
return person?.userId != null ? String(person.userId) : null; return person?.userId != null ? String(person.userId) : null;
} }
/** Guilty party row on the blame case (payer's insurer is on `person.clientId`). */
export function resolveGuiltyPartyPerson(
blame: Parameters<typeof resolveClaimOwnerParty>[0],
): { userId?: unknown; phoneNumber?: string; clientId?: unknown } | null {
const party = resolveClaimOwnerParty(blame);
return party?.person ?? null;
}
/**
* Claim `owner` fields for initiator-filled IN_PERSON claims (field expert / registrar).
* - `userId` = damaged party (SMS, user sign-off, claim beneficiary)
* - `clientId` = guilty party's insurer (damage-expert tenant queue)
*/
export function resolveClaimOwnerFieldsFromBlame(
blame: Parameters<typeof resolveDamagedPartyPerson>[0],
): {
userId: string;
userRole?: string;
clientId?: string;
} | null {
const damagedPerson = resolveDamagedPartyPerson(blame);
if (damagedPerson?.userId == null) return null;
const parties = blame?.parties ?? [];
const damagedUserId = String(damagedPerson.userId);
const damagedPartyRow = parties.find(
(p) =>
p.person?.userId != null &&
String(p.person.userId) === damagedUserId,
);
const guiltyPerson = resolveGuiltyPartyPerson(blame);
const payingClientId =
guiltyPerson?.clientId != null ? String(guiltyPerson.clientId) : undefined;
return {
userId: damagedUserId,
userRole: damagedPartyRow?.role,
...(payingClientId ? { clientId: payingClientId } : {}),
};
}

View File

@@ -5633,6 +5633,7 @@ export class RequestManagementService {
userId: firstPartyUserId, userId: firstPartyUserId,
phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ?? phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ??
formData.firstPartyPhoneNumber, formData.firstPartyPhoneNumber,
clientId: (client as any)?._id ?? (client as any)?._doc?._id,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer, nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver, nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
insurerLicense: firstPartyPlate.insurerLicense, insurerLicense: firstPartyPlate.insurerLicense,
@@ -5813,11 +5814,14 @@ export class RequestManagementService {
`Client not found for company: ${clientName}`, `Client not found for company: ${clientName}`,
); );
} }
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
return { return {
role, role,
person: { person: {
userId, userId,
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber, phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
...(resolvedClientId ? { clientId: resolvedClientId } : {}),
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer, nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
nationalCodeOfDriver: plateDto.nationalCodeOfDriver, nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
insurerLicense: plateDto.insurerLicense, insurerLicense: plateDto.insurerLicense,