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";
import {
blameDamagedPartyMatchesUser,
resolveClaimOwnerFieldsFromBlame,
resolveClaimOwnerParty,
resolveDamagedPartyUserId,
} 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.
* 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(
blameRequestId: string,
@@ -4702,29 +4703,11 @@ export class ClaimRequestManagementService {
"Only the field expert who created this blame file can create the claim.",
);
}
const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
let damagedUserId: string;
if (isCarBody) {
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,
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
if (!ownerFields) {
throw new BadRequestException(
"Could not resolve claim owner (damaged party) from blame",
);
if (!damagedParty?.person?.userId) {
throw new BadRequestException("Could not determine damaged party");
}
damagedUserId = String(damagedParty.person.userId);
}
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
@@ -4733,12 +4716,6 @@ export class ClaimRequestManagementService {
throw new ConflictException("A claim for this blame case already exists");
}
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({
requestNo: claimNo,
publicId: blameRequest.publicId,
@@ -4754,15 +4731,14 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
damagedPartyUserId: new Types.ObjectId(damagedUserId),
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
owner: {
userId: new Types.ObjectId(String(ownerParty.person.userId)),
userRole: ownerParty.role as any,
...(ownerParty.person.clientId
userId: new Types.ObjectId(ownerFields.userId),
userRole: ownerFields.userRole as any,
...(ownerFields.clientId
? {
clientId: new Types.ObjectId(
String(ownerParty.person.clientId),
),
clientId: new Types.ObjectId(ownerFields.clientId),
userClientKey: ownerFields.clientId,
}
: {}),
},
@@ -5020,26 +4996,11 @@ export class ClaimRequestManagementService {
"Only the registrar who created this blame file can create the claim.",
);
}
const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
let damagedUserId: string;
if (isCarBody) {
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,
const ownerFields = resolveClaimOwnerFieldsFromBlame(blameRequest);
if (!ownerFields) {
throw new BadRequestException(
"Could not resolve claim owner (damaged party) from blame",
);
if (!damagedParty?.person?.userId)
throw new BadRequestException("Could not determine damaged party");
damagedUserId = String(damagedParty.person.userId);
}
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
@@ -5047,12 +5008,6 @@ export class ClaimRequestManagementService {
if (existingClaim)
throw new ConflictException("A claim for this blame case already exists");
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({
requestNo: claimNo,
publicId: blameRequest.publicId,
@@ -5067,15 +5022,14 @@ export class ClaimRequestManagementService {
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
damagedPartyUserId: new Types.ObjectId(damagedUserId),
damagedPartyUserId: new Types.ObjectId(ownerFields.userId),
owner: {
userId: new Types.ObjectId(String(ownerParty.person.userId)),
userRole: ownerParty.role as any,
...(ownerParty.person.clientId
userId: new Types.ObjectId(ownerFields.userId),
userRole: ownerFields.userRole as any,
...(ownerFields.clientId
? {
clientId: new Types.ObjectId(
String(ownerParty.person.clientId),
),
clientId: new Types.ObjectId(ownerFields.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";
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 SCRYPT_PARAMS: ScryptOptions = {
N: 19456, // CPU/memory cost
r: 8, // block size
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 { PasswordHash } from "./password.types";

View File

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

View File

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

View File

@@ -440,12 +440,13 @@ export class ExpertClaimService {
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(
claim: any,
): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined;
const ownerUserId = String(claim.owner.userId);
const notifyUserId = claim?.damagedPartyUserId ?? claim?.owner?.userId;
if (!notifyUserId) return undefined;
const ownerUserId = String(notifyUserId);
if (claim.blameRequestId) {
const blame = await this.blameRequestDbService.findById(
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 { InjectModel } from "@nestjs/mongoose";
import { User } from "../schemas";
import { Model } from "mongoose";
import { Otp, User } from "../schemas";
@Injectable()
export class UserRepository {
@@ -10,7 +10,9 @@ export class UserRepository {
private readonly model: Model<User>,
) {}
async addUser() {}
async create(cellphoneNumber: string): Promise<User> {
return this.model.create({ cellphoneNumber });
}
async retrieveByCellPhoneNumber(
cellphoneNumber: string,
@@ -19,4 +21,19 @@ export class UserRepository {
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()
hash: string;
@Prop()
salt: string;
@Prop()
expiresAt: Date;
@Prop({ default: 0 })
attempts: number;
attempts?: number;
@Prop({ default: Date.now })
generatedAt: Date;
generatedAt?: Date;
}
export const OtpSchema = SchemaFactory.createForClass(Otp);

View File

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

View File

@@ -2,6 +2,7 @@ import { Types } from "mongoose";
import { partyPersonMatchesUser } from "./iran-mobile";
import {
blameDamagedPartyMatchesUser,
resolveClaimOwnerFieldsFromBlame,
resolveDamagedPartyPerson,
resolveGuiltyPartyUserId,
} from "./blame-damaged-party";
@@ -46,13 +47,30 @@ describe("blame damaged party helpers", () => {
).toBe(false);
});
it("matches damaged party by phone when userId differs (duplicate accounts)", () => {
expect(
partyPersonMatchesUser(
{ userId: damagedId, phoneNumber: "9123456789" },
{ sub: "other-user-id", username: "09123456789" },
[],
),
).toBe(true);
it("resolveClaimOwnerFieldsFromBlame uses damaged userId and guilty clientId", () => {
const guiltyClientId = new Types.ObjectId();
const blame = {
type: "THIRD_PARTY",
expert: { decision: { guiltyPartyId: guiltyId } },
parties: [
{
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);
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,
phoneNumber: normalizeIranMobile(formData.firstPartyPhoneNumber) ??
formData.firstPartyPhoneNumber,
clientId: (client as any)?._id ?? (client as any)?._doc?._id,
nationalCodeOfInsurer: firstPartyPlate.nationalCodeOfInsurer,
nationalCodeOfDriver: firstPartyPlate.nationalCodeOfDriver,
insurerLicense: firstPartyPlate.insurerLicense,
@@ -5813,11 +5814,14 @@ export class RequestManagementService {
`Client not found for company: ${clientName}`,
);
}
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
return {
role,
person: {
userId,
phoneNumber: normalizeIranMobile(phoneNumber) ?? phoneNumber,
...(resolvedClientId ? { clientId: resolvedClientId } : {}),
nationalCodeOfInsurer: plateDto.nationalCodeOfInsurer,
nationalCodeOfDriver: plateDto.nationalCodeOfDriver,
insurerLicense: plateDto.insurerLicense,