1
0
forked from Yara724/api

Merge pull request 'Fixed unified damagedParts + Simplified Captcha' (#110) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#110
This commit is contained in:
2026-06-07 10:34:53 +03:30
9 changed files with 228 additions and 20 deletions

View File

@@ -13,9 +13,9 @@ export class CaptchaService {
generate(): GeneratedCaptcha {
const captcha = svgCaptcha.create({
size: 5,
ignoreChars: "0oO1ilI",
noise: 3,
color: true,
ignoreChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
noise: 1,
color: false,
background: "#f8fafc",
width: 160,
height: 56,

View File

@@ -27,26 +27,48 @@ export function buildEnrichedDamagedParts(params: {
} = params;
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
// objectionParts stores { partId, carPartDamage, side } — not { id, name }
const objectionParts: any[] =
evaluationBlock?.objection?.objectionParts ?? [];
// newParts stores { partId, partName, side } — not { id, name }
const newObjectionParts: any[] = evaluationBlock?.objection?.newParts ?? [];
// Resend block — lives at evaluation.damageExpertResend
const damageExpertResend = evaluationBlock?.damageExpertResend;
const resendCarParts: any[] = damageExpertResend?.resendCarParts ?? [];
const resendFulfilledAt: Date | undefined = damageExpertResend?.fulfilledAt;
// Helpers that match against the ACTUAL stored field names
function partMatchesObjection(sp: any, objPart: any): boolean {
if (sp.id != null && objPart.partId != null && sp.id === objPart.partId)
return true;
const objName = objPart.carPartDamage ?? objPart.name ?? objPart.partName;
if (objName && (sp.name === objName || sp.label_fa === objName))
return true;
return false;
}
function partMatchesNewObjection(sp: any, newPart: any): boolean {
if (sp.id != null && newPart.partId != null && sp.id === newPart.partId)
return true;
const newName = newPart.partName ?? newPart.name;
if (newName && (sp.name === newName || sp.label_fa === newName))
return true;
return false;
}
function isPartInResend(sp: any): boolean {
return resendCarParts.some(
(r: any) =>
r.id === sp.id || r.name === sp.name || r.catalogKey === sp.catalogKey,
(sp.id != null && r.id === sp.id) ||
r.catalogKey === sp.catalogKey ||
r.name === sp.name,
);
}
function resolveOrigin(sp: any): EnrichedDamagedPart["origin"] {
if (
newObjectionParts.some((p: any) => p.id === sp.id || p.name === sp.name)
) {
if (newObjectionParts.some((p) => partMatchesNewObjection(sp, p))) {
return "added_by_objection";
}
if (
@@ -65,7 +87,6 @@ export function buildEnrichedDamagedParts(params: {
if (!captured) {
return inResend ? "resend_requested" : "pending";
}
// captured=true + was in a resend request that is now fulfilled → "resent"
return inResend && resendFulfilledAt ? "resent" : "uploaded";
}
@@ -77,8 +98,30 @@ export function buildEnrichedDamagedParts(params: {
ck,
selectedParts,
) as { url?: string; path?: string } | undefined;
const captured = hasDamagedPartCapture(damagedPartsData, ck, selectedParts);
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
// Fallback: if id is null but media.damagedParts has a match by name
const captured =
hasDamagedPartCapture(damagedPartsData, ck, selectedParts) ||
(sp.id == null && Array.isArray(damagedPartsData)
? (damagedPartsData as any[]).some(
(d) => d.name === sp.name && d.side === sp.side && d.path,
)
: false);
const capUrl =
cap?.url ||
(cap?.path ? buildFileLink(cap.path) : undefined) ||
(sp.id == null && Array.isArray(damagedPartsData)
? (() => {
const match = (damagedPartsData as any[]).find(
(d) => d.name === sp.name && d.side === sp.side && d.path,
);
return (
match?.url ||
(match?.path ? buildFileLink(match.path) : undefined)
);
})()
: undefined);
const matchingPriceDrop = priceDropLines.find(
(line: any) =>
@@ -86,11 +129,9 @@ export function buildEnrichedDamagedParts(params: {
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
);
const isObjected = objectionParts.some(
(p: any) => p.id === sp.id || p.name === sp.name,
);
const isNewlyAdded = newObjectionParts.some(
(p: any) => p.id === sp.id || p.name === sp.name,
const isObjected = objectionParts.some((p) => partMatchesObjection(sp, p));
const isNewlyAdded = newObjectionParts.some((p) =>
partMatchesNewObjection(sp, p),
);
const isNewByExpert = expertAddedParts.some(
(p: any) => p.id === sp.id || p.name === sp.name,
@@ -109,7 +150,7 @@ export function buildEnrichedDamagedParts(params: {
origin: resolveOrigin(sp),
captureStatus: resolveCaptureStatus(sp, captured),
captured,
url,
url: capUrl,
objection: {
isObjected,

View File

@@ -4122,7 +4122,7 @@ export class ExpertClaimService {
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
money: moneyPayload,
selectedParts: selectedNormExpert,
// selectedParts: selectedNormExpert,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
@@ -4134,7 +4134,7 @@ export class ExpertClaimService {
evaluation: enrichedEvaluation as
| ClaimDetailV2ResponseDto["evaluation"]
| undefined,
objection,
// objection,
videoCapture,
blameCase: blameCaseForApi,
blameExpertDecision,
@@ -4242,9 +4242,10 @@ export class ExpertClaimService {
);
}
// Manual path — change totalPriceDrop → total to match calculated path
const manualPayload = {
manualOverride: true,
totalPriceDrop: body.manualPriceDrop,
total: body.manualPriceDrop, // ← was totalPriceDrop
partLines: [],
};
@@ -4269,6 +4270,18 @@ export class ExpertClaimService {
}
// Calculated path — existing logic unchanged
// At the top of the calculated path, after the manualPriceDrop early return:
const existingPriceDrop = (claim as any).evaluation?.priceDrop;
if (
existingPriceDrop?.manualOverride === true &&
!body.partSeverities?.length
) {
throw new BadRequestException(
"This claim has a manual price-drop correction. " +
"To recalculate, provide partSeverities explicitly — this will replace the manual correction.",
);
}
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
claim.damage?.selectedParts,

View File

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,27 @@
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);