1
0
forked from Yara724/api

Merge pull request 'Fixed enrichedEvaluation' (#87) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#87
This commit is contained in:
2026-05-26 16:36:29 +03:30
12 changed files with 364 additions and 366 deletions

10
LICENSE
View File

@@ -1,10 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View File

@@ -1,2 +1 @@
# api

View File

@@ -1 +0,0 @@
{"1000":{"info":"start","message":"start"},"1001":{"info":"Access Denied Other Actor Lock File","message":""},"1004":{"info":"g","message":""},"1005":{"info":"fSF","message":""},"1006":{"info":"request not found ","message":""}}

49
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@fraybabak/kavenegar_nest": "^1.0.5",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mongoose": "^10.1.0",
@@ -2943,6 +2944,39 @@
}
}
},
"node_modules/@nestjs/config": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
"license": "MIT",
"dependencies": {
"dotenv": "17.4.1",
"dotenv-expand": "12.0.3",
"lodash": "4.18.1"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"rxjs": "^7.1.0"
}
},
"node_modules/@nestjs/config/node_modules/dotenv": {
"version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/@nestjs/core": {
"version": "10.4.15",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.15.tgz",
@@ -5564,6 +5598,21 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -23,6 +23,7 @@
"@fraybabak/kavenegar_nest": "^1.0.5",
"@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mongoose": "^10.1.0",

View File

@@ -4,15 +4,14 @@ import {
HttpException,
HttpStatus,
Injectable,
Logger,
OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import axios, { AxiosRequestConfig } from "axios";
import * as FormData from "form-data";
@Injectable()
export class AiService implements OnModuleInit {
private readonly logger = new Logger(AiService.name);
private apiKey: string;
private accessToken: string = null;
@@ -20,18 +19,20 @@ export class AiService implements OnModuleInit {
private readonly loginOptions: AxiosRequestConfig = {
method: "POST",
headers: { "Content-Type": "application/json" },
url: `${process.env.AI_URL_V2}/auth/login`,
url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
data: {
username: process.env.AI_USERNAME,
password: process.env.AI_PASSWORD,
username: this.configService.get<string>("AI_USERNAME"),
password: this.configService.get<string>("AI_PASSWORD"),
},
timeout: 1000, // 30 second timeout
timeout: 1000, // TODO: Make this ENV
};
constructor(private readonly configService: ConfigService) {}
private get profileOptions(): AxiosRequestConfig {
return {
method: "GET",
url: `${process.env.AI_URL_V2}/auth/profile`,
url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
@@ -50,28 +51,16 @@ export class AiService implements OnModuleInit {
};
}
constructor() {}
async onModuleInit() {
try {
const res = await this.login();
if (res?.accessToken) {
this.logger.verbose("AI Service Authenticated Successfully.");
this.accessToken = res.accessToken;
await this.getApiKey();
this.logger.log("AI Service initialized and ready.");
} else {
this.logger.warn(
"AI Service Unavailable: Login did not return an access token. Will retry on first request.",
);
}
} catch (error) {
// Don't prevent app startup if AI service is temporarily unavailable
// The service will attempt to re-authenticate when aiRequestImage is called
this.logger.warn(
"AI Service Unavailable: Failed during initial login. Will retry on first request.",
);
this.logger.warn(`Error: ${error.message}`);
// Reset tokens so re-authentication will be attempted
this.accessToken = null;
this.apiKey = null;
@@ -79,42 +68,22 @@ export class AiService implements OnModuleInit {
}
private async login() {
try {
const loginResponse = await axios.request(this.loginOptions);
return loginResponse.data;
} catch (err) {
const errorMessage = err.response?.data?.message || err.message || "Unknown error";
const statusCode = err.response?.status || 500;
this.logger.error(`AI login failed: ${errorMessage} (Status: ${statusCode})`);
if (err.response?.data) {
this.logger.error(`AI login error details: ${JSON.stringify(err.response.data, null, 2)}`);
}
throw new HttpException(
`Could not authenticate with AI service: ${errorMessage}`,
statusCode >= 400 && statusCode < 500 ? statusCode : HttpStatus.UNAUTHORIZED,
);
}
}
private async getApiKey() {
try {
const profileResponse = await axios.request(this.profileOptions);
this.apiKey = profileResponse.data.apiKey.key;
this.logger.log("Successfully retrieved AI gateway API key.");
return this.apiKey;
} catch (err) {
this.logger.error("Failed to retrieve AI API key:", err.message);
throw new HttpException(
"Could not get API key from AI service",
HttpStatus.FAILED_DEPENDENCY,
);
}
}
public async aiRequestImage(file: { path: string; fileName?: string }): Promise<any> {
public async aiRequestImage(file: {
path: string;
fileName?: string;
}): Promise<any> {
// Ensure authentication is set up
if (!this.accessToken || !this.apiKey) {
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
try {
const res = await this.login();
if (res?.accessToken) {
@@ -127,7 +96,6 @@ export class AiService implements OnModuleInit {
);
}
} catch (error) {
this.logger.error("Failed to re-authenticate AI service:", error.message);
throw new HttpException(
"AI Service authentication failed",
HttpStatus.UNAUTHORIZED,
@@ -140,11 +108,8 @@ export class AiService implements OnModuleInit {
? file.path
: join(process.cwd(), file.path.replace(/^\.\//, ""));
this.logger.log(`Processing AI image request for: ${filePath}`);
// Check if file exists
if (!existsSync(filePath)) {
this.logger.error(`File not found at path: ${filePath}`);
throw new HttpException(
`File not found: ${file.path}`,
HttpStatus.NOT_FOUND,
@@ -170,19 +135,8 @@ export class AiService implements OnModuleInit {
...form.getHeaders(),
};
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`);
this.logger.log(`[STEP 1/4] File: ${filePath}, Filename: ${file.fileName || 'extracted from path'}`);
this.logger.log(`[STEP 1/4] FormData Content-Type: ${form.getHeaders()['content-type']}`);
this.logger.log(`[STEP 1/4] Authorization header present: ${!!requestHeaders.Authorization}`);
this.logger.log(`[STEP 1/4] Gateway API key present: ${!!this.apiKey}`);
this.logger.log(`[STEP 1/4] Request method: POST`);
this.logger.log(`[STEP 1/4] FormData field name: "images"`);
// Get file stats for debugging
const fs = require('fs');
const fs = require("fs");
const stats = fs.statSync(filePath);
this.logger.log(`[STEP 1/4] File size: ${stats.size} bytes`);
this.logger.log(`[STEP 1/4] File exists: ${existsSync(filePath)}`);
const response = await axios.request({
...this.imageProcessOptions,
@@ -192,11 +146,8 @@ export class AiService implements OnModuleInit {
maxBodyLength: Infinity,
});
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`);
// Validate response structure
if (!response.data) {
this.logger.error(`[ERROR] AI response is empty or missing data`);
throw new HttpException(
"AI Service returned empty response",
HttpStatus.BAD_GATEWAY,
@@ -205,9 +156,6 @@ export class AiService implements OnModuleInit {
// Check for error in response first (AI service returns 201 with error in body)
if (response.data.error) {
this.logger.error(`[ERROR] AI service returned an error in response body`);
this.logger.error(`[ERROR] Error message: ${response.data.error}`);
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
throw new HttpException(
`AI Service error: ${response.data.error}`,
HttpStatus.BAD_GATEWAY,
@@ -216,23 +164,12 @@ export class AiService implements OnModuleInit {
// Check for processed image (downloadLink)
if (!response.data.downloadLink) {
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`);
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`);
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
throw new HttpException(
"AI Service did not return processed image (downloadLink missing)",
HttpStatus.BAD_GATEWAY,
);
}
// Check for reports
if (!response.data.reports) {
this.logger.warn(`[WARNING] AI response missing reports object, but downloadLink exists`);
this.logger.warn(`[WARNING] Response keys: ${JSON.stringify(Object.keys(response.data))}`);
}
this.logger.log(`[STEP 3/4] Validated AI response - downloadLink: ${response.data.downloadLink ? 'present' : 'missing'}, reports: ${response.data.reports ? 'present' : 'missing'}`);
return response.data;
} catch (er) {
// Determine error source
@@ -242,7 +179,10 @@ export class AiService implements OnModuleInit {
if (er.response) {
errorSource = "AI_SERVICE_RESPONSE";
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`;
errorMessage =
er.response?.data?.message ||
er.message ||
`HTTP ${er.response.status}`;
errorDetails = er.response?.data
? JSON.stringify(er.response.data, null, 2)
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
@@ -255,14 +195,6 @@ export class AiService implements OnModuleInit {
errorMessage = er.message || "Error setting up request";
}
this.logger.error(`[ERROR] AI request failed - Source: ${errorSource}`);
this.logger.error(`[ERROR] File path: ${filePath}`);
this.logger.error(`[ERROR] Error message: ${errorMessage}`);
this.logger.error(`[ERROR] Error details: ${errorDetails}`);
if (er.stack) {
this.logger.error(`[ERROR] Stack trace: ${er.stack}`);
}
// Re-throw with detailed error information
throw new HttpException(
`[${errorSource}] ${errorMessage}`,

View File

@@ -1,6 +1,7 @@
import { join } from "node:path";
import { Module, ValidationPipe } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
import { APP_INTERCEPTOR } from "@nestjs/core";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
import { MongooseModule } from "@nestjs/mongoose";
import { ScheduleModule } from "@nestjs/schedule";
@@ -30,6 +31,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
CronModule,
ServeStaticModule.forRoot({

View File

@@ -26,7 +26,6 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { LoggingInterceptor } from "src/interceptor/logging.interceptors";
import { RoleEnum } from "src/Types&Enums/role.enum";
import {
ResendFirstPartyDto,
@@ -135,7 +134,6 @@ export class ExpertBlameController {
return this.expertBlameService.streamVideo(requestId);
}
@UseInterceptors(LoggingInterceptor)
@ApiOperation({ deprecated: true })
@Get("voice/:requestId/:voiceId")
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
import { DamageSelectedPartV2BodyDto } from 'src/claim-request-management/dto/damage-selected-part-v2.dto';
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { DamageSelectedPartV2BodyDto } from "src/claim-request-management/dto/damage-selected-part-v2.dto";
export class ClaimDetailV2ResponseDto {
@ApiProperty()
@@ -21,7 +21,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional()
nextStep?: string;
@ApiProperty({ description: 'Whether claim is locked' })
@ApiProperty({ description: "Whether claim is locked" })
locked: boolean;
@ApiPropertyOptional()
@@ -47,64 +47,70 @@ export class ClaimDetailV2ResponseDto {
};
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
description: "Blame file type from linked blame case",
enum: BlameRequestType,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
"CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)",
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
@ApiPropertyOptional({ description: "Blame request ID", example: "507f..." })
blameRequestId?: string;
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
@ApiPropertyOptional({
description: "Blame request number",
example: "BL12345",
})
blameRequestNo?: string;
@ApiPropertyOptional({
description: 'Selected outer damaged parts (id, name, side, label_fa)',
description: "Selected outer damaged parts (id, name, side, label_fa)",
type: [DamageSelectedPartV2BodyDto],
})
selectedParts?: DamageSelectedPartV2BodyDto[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
@ApiPropertyOptional({ description: "Selected other damaged parts" })
otherParts?: string[];
@ApiPropertyOptional({ description: 'Required documents status' })
requiredDocuments?: Record<string, {
@ApiPropertyOptional({ description: "Required documents status" })
requiredDocuments?: Record<
string,
{
uploaded: boolean;
fileId?: string;
fileName?: string;
filePath?: string;
}>;
}
>;
@ApiPropertyOptional({ description: 'Car angles captured' })
@ApiPropertyOptional({ description: "Car angles captured" })
carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
'Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)',
type: 'array',
"Per-part captures (array index matches selectedParts; includes id, name, side, label_fa, captured, url)",
type: "array",
items: {
type: 'object',
type: "object",
properties: {
index: { type: 'number' },
index: { type: "number" },
partId: {
type: 'number',
type: "number",
nullable: true,
description:
'Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop',
"Numeric catalog part id for outer parts (`null` for non-catalog lines); use in reply/submit and price-drop",
},
id: { type: 'number', nullable: true },
name: { type: 'string' },
side: { type: 'string' },
label_fa: { type: 'string' },
captured: { type: 'boolean' },
url: { type: 'string' },
id: { type: "number", nullable: true },
name: { type: "string" },
side: { type: "string" },
label_fa: { type: "string" },
captured: { type: "boolean" },
url: { type: "string" },
},
},
})
@@ -121,24 +127,25 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
'True when user uploaded all required factors and the case awaits expert approve/reject.',
"True when user uploaded all required factors and the case awaits expert approve/reject.",
})
awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
'Slice of `claim.evaluation` exposed to the damage expert. ' +
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' +
'awaiting factor validation. `ownerInsurerApproval` and ' +
'`ownerPricedPartsApproval` are returned whenever they exist on the ' +
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' +
'front-end can render the user signature directly. Likewise, ' +
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' +
'expose `signLink` when a user comment signature is present.',
"Slice of `claim.evaluation` exposed to the damage expert. " +
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
"awaiting factor validation. `ownerInsurerApproval` and " +
"`ownerPricedPartsApproval` are returned whenever they exist on the " +
"claim — each carries `signLink` (resolved from `signDetailId`) so the " +
"front-end can render the user signature directly. Likewise, " +
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
"expose `signLink` when a user comment signature is present.",
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
damageExpertResend: unknown;
ownerInsurerApproval?: {
agree: boolean;
branchId?: string;
@@ -166,7 +173,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
"Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.",
})
objection?: {
objectionParts?: Array<Record<string, unknown>>;
@@ -176,7 +183,7 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
"Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).",
})
videoCapture?: {
id: string;
@@ -187,18 +194,19 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({
description:
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
"Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.",
})
blameCase?: Record<string, unknown>;
@ApiPropertyOptional({
description:
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
"Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.",
})
blameExpertDecision?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
description:
"Claim payout / banking metadata from the claim file (e.g. Sheba).",
})
money?: {
sheba?: string;

View File

@@ -73,7 +73,10 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
import {
GetClaimListV2ResponseDto,
ClaimListItemV2Dto,
} from "./dto/claim-list-v2.dto";
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
@@ -257,7 +260,7 @@ export class ExpertClaimService {
private readonly userDbService: UserDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly claimSignDbService: ClaimSignDbService,
) { }
) {}
/**
* Resolve a `claim-sign` document id to a downloadable file URL.
@@ -294,7 +297,10 @@ export class ExpertClaimService {
evaluation: Record<string, unknown> | undefined | null,
): Promise<Record<string, unknown> | undefined> {
if (!evaluation || typeof evaluation !== "object") return undefined;
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<string, unknown>;
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<
string,
unknown
>;
const enrichReply = async (key: string) => {
const reply = ev[key] as Record<string, unknown> | undefined;
@@ -389,7 +395,9 @@ export class ExpertClaimService {
}
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
private async resolveClaimOwnerPhone(
claim: any,
): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined;
const ownerUserId = String(claim.owner.userId);
if (claim.blameRequestId) {
@@ -412,12 +420,14 @@ export class ExpertClaimService {
}
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
private expertVehicleFromPartyVehicle(v: any): {
private expertVehicleFromPartyVehicle(v: any):
| {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
} | undefined {
}
| undefined {
if (!v || typeof v !== "object") return undefined;
const carName = v.name ?? v.carName;
const carModel = v.model ?? v.carModel;
@@ -449,16 +459,14 @@ export class ExpertClaimService {
const ownerId = claim?.owner?.userId ? String(claim.owner.userId) : "";
let party = ownerId
? parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) === ownerId,
(p: any) => p?.person?.userId && String(p.person.userId) === ownerId,
)
: undefined;
if (!party && blame?.expert?.decision?.guiltyPartyId) {
const guilty = String(blame.expert.decision.guiltyPartyId);
party = parties.find(
(p: any) =>
p?.person?.userId && String(p.person.userId) !== guilty,
(p: any) => p?.person?.userId && String(p.person.userId) !== guilty,
);
}
@@ -471,13 +479,15 @@ export class ExpertClaimService {
claim: any,
blameById: Map<string, any>,
):
| { carName?: string; carModel?: string; carType?: string; plate?: { plateId?: string } }
| {
carName?: string;
carModel?: string;
carType?: string;
plate?: { plateId?: string };
}
| undefined {
const cv = claim?.vehicle;
if (
cv &&
(cv.carName || cv.carModel || cv.carType || (cv as any).plate)
) {
if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) {
return {
carName: cv.carName,
carModel: cv.carModel,
@@ -498,7 +508,7 @@ export class ExpertClaimService {
private blameFileContextForExpert(blame: any): {
blameRequestType?: BlameRequestType;
carBodyFirstForm?: { car?: boolean; object?: boolean };
blameStatus?: string
blameStatus?: string;
} {
if (!blame?.type) return {};
const blameRequestType = blame.type as BlameRequestType;
@@ -516,7 +526,7 @@ export class ExpertClaimService {
const first =
parties.find((p: any) => p?.role === PartyRole.FIRST) ?? parties[0];
const cbf = first?.carBodyFirstForm;
delete out.blameStatus
delete out.blameStatus;
if (cbf && typeof cbf === "object") {
out.carBodyFirstForm = {
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
@@ -592,7 +602,7 @@ export class ExpertClaimService {
"damageExpertReply.actorDetail.actorId": actor.sub,
},
],
}
},
);
const filteredRequests = [];
@@ -681,14 +691,17 @@ export class ExpertClaimService {
const lockedByCurrent =
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime();
!!request.unlockTime &&
Date.now() >= new Date(request.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Claim request is locked by another expert");
throw new BadRequestException(
"Claim request is locked by another expert",
);
}
if (request.claimStatus === ReqClaimStatus.UserPending) {
@@ -747,8 +760,8 @@ export class ExpertClaimService {
}
private normalizeText(str: string) {
if (!str || typeof str !== 'string') {
return '';
if (!str || typeof str !== "string") {
return "";
}
return str
.replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
@@ -779,10 +792,8 @@ export class ExpertClaimService {
const tp =
decision.totalPayment != null &&
String(decision.totalPayment).trim() !== "";
const pr =
decision.price != null && String(decision.price).trim() !== "";
const sa =
decision.salary != null && String(decision.salary).trim() !== "";
const pr = decision.price != null && String(decision.price).trim() !== "";
const sa = decision.salary != null && String(decision.salary).trim() !== "";
return tp || (pr && sa);
}
@@ -925,7 +936,7 @@ export class ExpertClaimService {
}
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
const requestId = request?._id?.toString() || 'unknown';
const requestId = request?._id?.toString() || "unknown";
try {
// Step 1: Check SandHub data
@@ -946,7 +957,10 @@ export class ExpertClaimService {
return;
}
if (sandHubData.MapTypNam === undefined || sandHubData.MapTypNam === null) {
if (
sandHubData.MapTypNam === undefined ||
sandHubData.MapTypNam === null
) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`,
);
@@ -957,7 +971,10 @@ export class ExpertClaimService {
const manualOverride = query || {};
// Step 2: Check car parts and get severity values
if (!carPartsAi || (Array.isArray(carPartsAi) && carPartsAi.length === 0)) {
if (
!carPartsAi ||
(Array.isArray(carPartsAi) && carPartsAi.length === 0)
) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`,
);
@@ -1035,7 +1052,11 @@ export class ExpertClaimService {
}
// Step 5: Log calculation result
if (!finalPriceDropData.carPrice || !finalPriceDropData.carValue || finalPriceDropData.carValue.length === 0) {
if (
!finalPriceDropData.carPrice ||
!finalPriceDropData.carValue ||
finalPriceDropData.carValue.length === 0
) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Price drop calculated but missing critical data. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
@@ -1146,9 +1167,7 @@ export class ExpertClaimService {
for (const item of endpoints) {
try {
const url = `${process.env.CW_URL}price?${item}`;
const response = await lastValueFrom(
this.httpService.get(url),
);
const response = await lastValueFrom(this.httpService.get(url));
if (Array.isArray(response.data)) {
let validEntries = 0;
for (const r of response.data) {
@@ -1178,7 +1197,7 @@ export class ExpertClaimService {
if (carPrices.length === 0) {
this.logger.error(
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(', ')}`,
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(", ")}`,
);
} else {
this.logger.debug(
@@ -1234,7 +1253,10 @@ export class ExpertClaimService {
await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal);
// 2. Populate required documents with file links
if (requestUpdated.requiredDocuments && Object.keys(requestUpdated.requiredDocuments).length > 0) {
if (
requestUpdated.requiredDocuments &&
Object.keys(requestUpdated.requiredDocuments).length > 0
) {
for (const documentType in requestUpdated.requiredDocuments) {
const documentId = requestUpdated.requiredDocuments[documentType];
if (documentId) {
@@ -1244,7 +1266,9 @@ export class ExpertClaimService {
);
if (doc && doc.path) {
// Replace ID with file URL
requestUpdated.requiredDocuments[documentType] = buildFileLink(doc.path) as any;
requestUpdated.requiredDocuments[documentType] = buildFileLink(
doc.path,
) as any;
}
} catch (error) {
this.logger.warn(
@@ -1384,9 +1408,8 @@ export class ExpertClaimService {
}
// Fetch all required documents for this claim
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
requestId,
);
const documents =
await this.claimRequiredDocumentDbService.findByClaimId(requestId);
// Populate with file URLs
const populatedDocuments = documents.map((doc) => ({
@@ -1443,7 +1466,10 @@ export class ExpertClaimService {
}
private isRequestLocked(request: any, currentUser?: any): boolean {
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
if (
!request.lockFile ||
request.claimStatus !== ReqClaimStatus.ReviewRequest
) {
return false;
}
@@ -2370,7 +2396,8 @@ export class ExpertClaimService {
assertClaimCaseForTenant(claim, actor);
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
const isFactorValidationLock =
claimIsAwaitingExpertFactorValidationV2(claim);
const isDamageReviewLock =
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
@@ -2657,7 +2684,7 @@ export class ExpertClaimService {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
@@ -2670,12 +2697,12 @@ export class ExpertClaimService {
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a resend request',
"You must lock the claim before submitting a resend request",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
throw new ForbiddenException("This claim is locked by another expert");
}
// Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend).
@@ -2815,14 +2842,14 @@ export class ExpertClaimService {
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto,
actor: any,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
@@ -2835,31 +2862,33 @@ export class ExpertClaimService {
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a reply',
"You must lock the claim before submitting a reply",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
throw new ForbiddenException("This claim is locked by another expert");
}
// Price cap validation
const PRICE_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `You have reached the maximum acceptable total price (Toman). The sum of priced parts and factor lines (${totalPrice.toLocaleString()}) exceeds the limit (${PRICE_CAP.toLocaleString()}).`,
error: 'PRICE_CAP_ERROR',
error: "PRICE_CAP_ERROR",
totalPrice,
priceCap: PRICE_CAP,
});
}
const carTypeSubmit = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const carTypeSubmit = claim.vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const daghiNormalized =
reply.parts?.length > 0
? this.validateAndNormalizeDaghiForExpertReplyV2(reply.parts)
@@ -2933,9 +2962,8 @@ export class ExpertClaimService {
};
});
const { mixedFactorAndPrice, allFactorLines } = classifyV2ExpertPricingParts(
processedParts,
);
const { mixedFactorAndPrice, allFactorLines } =
classifyV2ExpertPricingParts(processedParts);
const needsFactorUpload =
reply.parts?.some((p) => p.factorNeeded === true) ?? false;
@@ -2944,14 +2972,14 @@ export class ExpertClaimService {
if (objectionSubmitted && hasFinalReply) {
throw new ConflictException(
'A final expert reply after objection already exists for this claim.',
"A final expert reply after objection already exists for this claim.",
);
}
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
const replyField = isFinalReplyAfterObjection
? 'damageExpertReplyFinal'
: 'damageExpertReply';
? "damageExpertReplyFinal"
: "damageExpertReply";
const completedStep = isFinalReplyAfterObjection
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
@@ -3006,28 +3034,30 @@ export class ExpertClaimService {
...(expertAddedParts.length > 0
? { "damage.selectedParts": mergedSelectedParts }
: {}),
'workflow.locked': false,
"workflow.locked": false,
$unset: {
'workflow.lockedAt': '',
'workflow.expiredAt': '',
'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
'evaluation.ownerInsurerApproval': "",
'evaluation.ownerPricedPartsApproval': "",
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
"evaluation.ownerInsurerApproval": "",
"evaluation.ownerPricedPartsApproval": "",
},
'workflow.currentStep': currentStep,
'workflow.nextStep': needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.currentStep": currentStep,
"workflow.nextStep": needsFactorUpload
? nextWorkflowStep
: ClaimWorkflowStep.CLAIM_COMPLETED,
[`evaluation.${replyField}`]: replyPayload,
$push: {
'workflow.completedSteps': completedStep,
"workflow.completedSteps": completedStep,
history: {
type: isFinalReplyAfterObjection
? 'EXPERT_FINAL_REPLY_SUBMITTED'
: 'EXPERT_REPLY_SUBMITTED',
? "EXPERT_FINAL_REPLY_SUBMITTED"
: "EXPERT_REPLY_SUBMITTED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: 'damage_expert',
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
@@ -3042,7 +3072,10 @@ export class ExpertClaimService {
},
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
updatePayload,
);
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
@@ -3070,7 +3103,9 @@ export class ExpertClaimService {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep,
workflowNextStep: needsFactorUpload ? nextWorkflowStep : ClaimWorkflowStep.CLAIM_COMPLETED,
workflowNextStep: needsFactorUpload
? nextWorkflowStep
: ClaimWorkflowStep.CLAIM_COMPLETED,
factorNeeded: needsFactorUpload,
mixedPricingAndFactors: mixedFactorAndPrice,
allPartsFactorNeeded: !!needsFactorUpload && !!allFactorLines,
@@ -3095,12 +3130,16 @@ export class ExpertClaimService {
* - Records history event IN_PERSON_VISIT_REQUESTED
* - Unlocks the workflow so user can act
*/
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
async requestInPersonVisitV2(
claimRequestId: string,
actor: any,
note?: string,
) {
requireActorClientKey(actor);
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
@@ -3113,28 +3152,28 @@ export class ExpertClaimService {
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before requesting an in-person visit',
"You must lock the claim before requesting an in-person visit",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
throw new ForbiddenException("This claim is locked by another expert");
}
const visitSnapshot = await this.snapshotDamageExpert(actor.sub);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
"workflow.locked": false,
$unset: {
'workflow.lockedAt': '',
'workflow.expiredAt': '',
'workflow.lockedBy': '',
'workflow.preLockQueueSnapshot': '',
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
"workflow.preLockQueueSnapshot": "",
},
...(note ? { 'evaluation.visitLocation': note } : {}),
...(note ? { "evaluation.visitLocation": note } : {}),
...(visitSnapshot && {
'evaluation.inPersonVisitExpertProfileSnapshot': visitSnapshot,
"evaluation.inPersonVisitExpertProfileSnapshot": visitSnapshot,
}),
$push: {
history: {
@@ -3199,7 +3238,10 @@ export class ExpertClaimService {
const rows =
orClauses.length === 0
? []
: await this.claimCaseDbService.find({ $or: orClauses }, { lean: true });
: await this.claimCaseDbService.find(
{ $or: orClauses },
{ lean: true },
);
const buckets = initialClaimExpertReportBuckets();
const seen = new Set<string>();
@@ -3207,12 +3249,7 @@ export class ExpertClaimService {
const id = String(doc._id ?? "");
if (seen.has(id)) continue;
if (
!claimDocInExpertPortfolio(
doc,
expertId,
activityFileIds,
clientKey,
)
!claimDocInExpertPortfolio(doc, expertId, activityFileIds, clientKey)
) {
continue;
}
@@ -3223,8 +3260,15 @@ export class ExpertClaimService {
}
// Add new calculated fields for front-end
buckets["PENDING_ON_USER"] = buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] + buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] + buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] + buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] + buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING];
buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT]
buckets["PENDING_ON_USER"] =
buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] +
buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] +
buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] +
buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] +
buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING];
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] +
buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT];
return buckets;
}
@@ -3252,17 +3296,17 @@ export class ExpertClaimService {
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
"workflow.locked": { $ne: true },
},
// Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
'workflow.locked': { $ne: true },
"workflow.locked": { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
"workflow.locked": true,
"workflow.lockedBy.actorId": new Types.ObjectId(actorId),
},
// User uploaded all factors; expert must approve/reject (unlocked queue)
{
@@ -3270,12 +3314,12 @@ export class ExpertClaimService {
{
status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
{
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
],
},
@@ -3288,8 +3332,7 @@ export class ExpertClaimService {
const staleLockToReconcile = filtered.filter(
(c) =>
c.workflow?.locked &&
!this.isClaimV2WorkflowLockCurrentlyEnforced(c),
c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
);
const touchedIds = (
await Promise.all(
@@ -3306,9 +3349,7 @@ export class ExpertClaimService {
const refreshed = await this.claimCaseDbService.find({
_id: { $in: touchedIds.map((id) => new Types.ObjectId(id)) },
});
const byId = new Map(
refreshed.map((r) => [String(r._id), r] as const),
);
const byId = new Map(refreshed.map((r) => [String(r._id), r] as const));
for (const c of filtered) {
const r = byId.get(String(c._id));
if (r) Object.assign(c, r);
@@ -3334,14 +3375,16 @@ export class ExpertClaimService {
);
const list = filtered.map((c) => {
const awaitingFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
const awaitingFactorValidation =
claimIsAwaitingExpertFactorValidationV2(c);
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
const blame = c.blameRequestId
? blameById.get(c.blameRequestId.toString())
: undefined;
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
const lockActive =
!!(c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c));
const lockActive = !!(
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
);
const statusForList =
c.status === ClaimCaseStatus.EXPERT_REVIEWING
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
@@ -3374,7 +3417,9 @@ export class ExpertClaimService {
};
}) as ClaimListItemV2Dto[];
const paged = applyListQueryV2(list, {
const paged = applyListQueryV2(
list,
{
publicId: (r) => r.publicId,
createdAt: (r) => r.createdAt,
requestNo: (r) => r.publicId,
@@ -3387,7 +3432,9 @@ export class ExpertClaimService {
r.vehicle?.carModel,
r.blameRequestType,
].filter(Boolean) as string[],
}, query);
},
query,
);
return {
list: paged.list,
@@ -3405,17 +3452,17 @@ export class ExpertClaimService {
private async buildBlameCaseSnapshotForClaimDetail(
blameRequestId: string,
): Promise<Record<string, unknown> | null> {
const doc = await this.blameRequestDbService.findByIdWithoutHistory(
blameRequestId,
);
const doc =
await this.blameRequestDbService.findByIdWithoutHistory(blameRequestId);
if (!doc) {
return null;
}
const docRec = doc as Record<string, unknown>;
const built = buildMutualAgreementExpertDecision(docRec);
const existingDecision = (docRec.expert as Record<string, unknown> | undefined)
?.decision as Record<string, unknown> | undefined;
const existingDecision = (
docRec.expert as Record<string, unknown> | undefined
)?.decision as Record<string, unknown> | undefined;
if (built && !existingDecision?.guiltyPartyId) {
const prevExpert =
typeof docRec.expert === "object" && docRec.expert !== null
@@ -3524,18 +3571,18 @@ export class ExpertClaimService {
return {
...d,
guiltyPartyId:
guilty != null && typeof (guilty as { toString?: () => string }).toString === "function"
guilty != null &&
typeof (guilty as { toString?: () => string }).toString === "function"
? String(guilty)
: guilty,
decidedByExpertId:
decidedBy != null &&
typeof (decidedBy as { toString?: () => string }).toString === "function"
typeof (decidedBy as { toString?: () => string }).toString ===
"function"
? String(decidedBy)
: decidedBy,
decidedAt:
decidedAt instanceof Date
? decidedAt.toISOString()
: decidedAt,
decidedAt instanceof Date ? decidedAt.toISOString() : decidedAt,
};
}
@@ -3563,10 +3610,7 @@ export class ExpertClaimService {
}
const la = claim.workflow?.lockedAt as Date | string | undefined;
if (!la) return true;
return (
Date.now() <
new Date(la).getTime() + this.claimV2WorkflowLockTtlMs
);
return Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs;
}
/**
@@ -3591,10 +3635,7 @@ export class ExpertClaimService {
$and: [
{ $eq: ["$workflow.locked", true] },
{
$lte: [
{ $add: ["$workflow.lockedAt", lockTtlMs] },
now,
],
$lte: [{ $add: ["$workflow.lockedAt", lockTtlMs] }, now],
},
],
},
@@ -3608,9 +3649,7 @@ export class ExpertClaimService {
claimCaseTouchesClient(c, clientKey),
);
await Promise.all(
relevant.map((c) =>
this.expireClaimWorkflowLockV2IfStale(String(c._id)),
),
relevant.map((c) => this.expireClaimWorkflowLockV2IfStale(String(c._id))),
);
}
@@ -3650,7 +3689,9 @@ export class ExpertClaimService {
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(String(lockedById));
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
String(lockedById),
);
}
const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
@@ -3743,7 +3784,7 @@ export class ExpertClaimService {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
throw new NotFoundException("Claim request not found");
}
assertClaimCaseForTenant(claim, actor);
@@ -3782,7 +3823,8 @@ export class ExpertClaimService {
? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs);
for (const k of keys as string[]) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
const doc =
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
@@ -3796,7 +3838,7 @@ export class ExpertClaimService {
const carAnglesData = claim.media?.carAngles as any;
const damagedPartsDataForAngles = claim.media?.damagedParts as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
for (const k of ["front", "back", "left", "right"]) {
const cap = getClaimCarAngleCaptureBlob(
carAnglesData,
damagedPartsDataForAngles,
@@ -3809,7 +3851,9 @@ export class ExpertClaimService {
}
// Build damaged parts list (aligned with normalized selected parts)
const carTypeExpert = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const carTypeExpert = claim.vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const selectedNormExpert = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carTypeExpert,
@@ -3856,11 +3900,7 @@ export class ExpertClaimService {
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
const blameRequestIdStr = claim.blameRequestId?.toString();
const [
videoCaptureRow,
blameCase,
blameLeanRows
] = await Promise.all([
const [videoCaptureRow, blameCase, blameLeanRows] = await Promise.all([
videoCaptureIdRaw
? this.claimVideoCaptureDbService.findById(String(videoCaptureIdRaw))
: Promise.resolve(null),
@@ -3891,7 +3931,7 @@ export class ExpertClaimService {
: {};
// 4. videoCapture logic from "stashed"
let videoCapture: ClaimDetailV2ResponseDto['videoCapture'] = undefined;
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
if (videoCaptureRow) {
const vc = videoCaptureRow as any;
videoCapture = {
@@ -3908,8 +3948,9 @@ export class ExpertClaimService {
)
: undefined;
const money = (claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } })
.money;
const money = (
claim as { money?: { sheba?: string; nationalCodeOfInsurer?: string } }
).money;
const moneyPayload =
money && (money.sheba != null || money.nationalCodeOfInsurer != null)
? {
@@ -3948,9 +3989,8 @@ export class ExpertClaimService {
const claimEvaluationRaw = (claim as any).evaluation as
| Record<string, unknown>
| undefined;
const enrichedEvaluation = await this.enrichClaimEvaluationForExpert(
claimEvaluationRaw,
);
const enrichedEvaluation =
await this.enrichClaimEvaluationForExpert(claimEvaluationRaw);
let evaluationForApi: Record<string, unknown> | undefined;
if (enrichedEvaluation) {
@@ -3973,10 +4013,7 @@ export class ExpertClaimService {
evaluationForApi.ownerPricedPartsApproval =
enrichedEvaluation.ownerPricedPartsApproval;
}
if (
isDamageExpertPhase &&
enrichedEvaluation.priceDrop !== undefined
) {
if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) {
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
}
if (Object.keys(evaluationForApi).length === 0) {
@@ -3988,8 +4025,8 @@ export class ExpertClaimService {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
status: statusForExpertApi,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
claimStatus: claim.claimStatus || "PENDING",
currentStep: claim.workflow?.currentStep || "",
nextStep: claim.workflow?.nextStep,
locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy
@@ -4022,7 +4059,7 @@ export class ExpertClaimService {
carAngles,
damagedParts,
awaitingFactorValidation: isFactorValidationPending,
evaluation: evaluationForApi as
evaluation: enrichedEvaluation as
| ClaimDetailV2ResponseDto["evaluation"]
| undefined,
objection,
@@ -4142,7 +4179,9 @@ export class ExpertClaimService {
carModelYear =
resolveVehicleModelYearFromBlame(
claim,
blameRows[0] as Parameters<typeof resolveVehicleModelYearFromBlame>[1],
blameRows[0] as Parameters<
typeof resolveVehicleModelYearFromBlame
>[1],
) ?? undefined;
}
if (carModelYear == null || !Number.isFinite(carModelYear)) {
@@ -4273,7 +4312,9 @@ export class ExpertClaimService {
if (byId >= 0) return byId;
}
if (next.catalogKey) {
const byCk = previous.findIndex((x) => x.catalogKey === next.catalogKey);
const byCk = previous.findIndex(
(x) => x.catalogKey === next.catalogKey,
);
if (byCk >= 0) return byCk;
}
return previous.findIndex(

View File

@@ -1,20 +0,0 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log("Before...");
const now = Date.now();
return next
.handle()
.pipe(tap(() => console.log(`After... ${Date.now() - now}ms`)));
}
}

View File

@@ -28,7 +28,6 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle("yara724-backend")
.setVersion("1.0.0")
.addServer(process.env.URL)
.addServer(process.env.BASE_URL + "/api")
.addServer("http://192.168.20.170:9001")
.addServer("http://localhost:9001")