1
0
forked from Yara724/api
Files
yara724-api/src/expert-claim/expert-claim.service.ts

1973 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
import { createReadStream, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { HttpService } from "@nestjs/axios";
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
import { Types } from "mongoose";
import { lastValueFrom } from "rxjs";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { buildFileLink } from "src/helpers/urlCreator";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { SandHubService } from "src/sand-hub/sand-hub.service";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
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 { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
@Injectable()
export class ExpertClaimService {
private readonly logger = new Logger(ExpertClaimService.name);
private readonly priceDropPart = {
backFender: {
Minor: 2,
Moderate: 3,
Severe: 5,
},
backDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontDoor: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontFender: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
frontBumper: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
Hood: {
Minor: 2,
moderate: 3,
Severe: 4,
},
Trunk: {
minor: 1,
moderate: 3,
Severe: 5,
},
Roof: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
coil: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
column: {
Minor: 2,
Moderate: 3,
Severe: 4,
},
frontTray: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
backTray: {
Minor: 2,
moderate: 4,
Severe: 5,
},
frontChassis: {
Minor: 3,
Moderate: 5,
Severe: 7,
},
backChassis: {
Minor: 2,
Moderate: 4,
Severe: 6,
},
carFootrest: {
Minor: 1,
Moderate: 2,
Severe: 3,
},
carFloor: {
Minor: 4,
moderate: 6,
Severe: 8,
},
};
private readonly yearCoefficient = {
1393: 2.05,
1394: 2.1,
1395: 2.2,
1396: 2.3,
1397: 2.4,
1398: 2.5,
1399: 2.6,
1400: 2.7,
1401: 2.8,
1402: 2.9,
1403: 3,
1404: 3,
};
constructor(
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly damageImageDbService: DamageImageDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly branchDbService: BranchDbService,
) {}
private isVisibleToClientType(client: any, actor: any): boolean {
if (actor.userType === UserType.GENUINE) {
return true;
}
if (
actor.userType === UserType.LEGAL &&
String(client._id) === actor.clientKey
) {
return true;
}
return false;
}
private wasHandledByActor(request: any, actorSub: string): boolean {
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
if (!Array.isArray(actorChecker)) {
return false;
}
const matchingEntry = actorChecker.find(
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
);
return !!matchingEntry;
}
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
console.log(actor);
// const requests = await this.claimRequestManagementDbService.findAllByStatus(
// {
// claimStatus: {
// $in: [
// ReqClaimStatus.UnChecked,
// ReqClaimStatus.ReviewRequest,
// ReqClaimStatus.CheckAgain,
// ReqClaimStatus.CloseRequest,
// ReqClaimStatus.InPersonVisit,
// ReqClaimStatus.CheckedRequest,
// ReqClaimStatus.PendingFactorValidation,
// ],
// },
// "damageExpertReply.actorDetail.actorId":actor.sub
// },
// );
const requests = await this.claimRequestManagementDbService.findAllByStatus(
{
$or: [
{
claimStatus: ReqClaimStatus.UnChecked,
},
{
claimStatus: {
$in: [
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
],
},
"damageExpertReply.actorDetail.actorId": actor.sub,
},
],
}
);
const filteredRequests = [];
for (const r of requests) {
// For expert-initiated blame files, only show to the initiating expert
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
if (String(r.blameFile.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated claim files are always visible to the initiating expert
filteredRequests.push(r);
continue;
}
const client = await this.clientDbService.findOne({
_id: r.userClientKey,
});
if (!client) {
this.logger.warn(
`Client not found for claim request with ID: ${r._id}. Skipping.`,
);
continue;
}
const specialHandlingStatuses = [
ReqClaimStatus.CheckAgain,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation,
];
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
}
}
}
if (filteredRequests.length === 0) {
throw new NotFoundException(
"No claim requests found for you at this time.",
);
}
return new ClaimListDtoRs(filteredRequests);
}
public unlockApi(request, timer) {
// ADD to project
return setTimeout(() => {
this.claimRequestManagementDbService.findOne(request._id).then((r) => {
const updateExp = {
lockFile: false,
unlockTime: null,
actorLocked: {},
};
if (r?.claimStatus == ReqClaimStatus.ReviewRequest) {
Object.assign(updateExp, {
claimStatus: ReqClaimStatus.UnChecked,
});
}
this.claimRequestManagementDbService.findOneAndUpdate(request._id, {
...updateExp,
});
});
this.logger.log(`unlock this request : ${request._id}`);
// TODO enum request claimStatus create
}, timer);
}
async lockClaimRequest(requestId: string, actorDetail) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new BadRequestException("Claim request not found");
if (request.lockFile) {
throw new BadRequestException("Claim request is locked");
}
if (request.claimStatus === ReqClaimStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because claimStatus is UserPending",
);
}
if (
request.damageExpertReplyFinal &&
request.claimStatus === ReqClaimStatus.CheckedRequest
) {
throw new BadRequestException("Request has damage expert reply");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
await this.claimRequestManagementDbService.findOneAndUpdate(
new Types.ObjectId(requestId),
{
lockFile: true,
claimStatus: ReqClaimStatus.ReviewRequest,
unlockTime: fifteenMinutes,
lockTime: Date.now(),
$set: {
actorLocked: {
actorId: new Types.ObjectId(actorDetail.sub),
fullName: actorDetail.fullName,
},
},
$push: {
actorsChecker: {
[ReqClaimStatus.ReviewRequest]: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
},
Date: new Date(),
},
},
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"checked",
requestId,
);
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
return { _id: requestId, lock: true };
}
private normalizeText(str: string) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/[٠-٩۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/[\u064B-\u065F]/g, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
normalizeKey(str: string): string {
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
}
parsePersianNumber(input: string): number {
return Number(
input
.replace(/[۰-۹]/g, (d) => String.fromCharCode(d.charCodeAt(0) - 1728))
.replace(/,/g, ""),
);
}
private findClosestCar(input: string, cars: { carName: string }[]) {
if (!input || !cars || cars.length === 0) {
this.logger.debug(
`[PriceDrop] findClosestCar: Invalid input. input: ${input}, cars length: ${cars?.length || 0}`,
);
return null;
}
const normalizedInput = this.normalizeText(input);
if (!normalizedInput) {
this.logger.debug(
`[PriceDrop] findClosestCar: Could not normalize input "${input}"`,
);
return null;
}
let bestMatch = null;
let minDistance = Infinity;
let validCarsChecked = 0;
for (const car of cars) {
if (!car || !car.carName) {
continue;
}
const normalizedCarName = this.normalizeText(car.carName);
if (!normalizedCarName) {
continue;
}
validCarsChecked++;
const dist = stringDistance(normalizedInput, normalizedCarName);
if (dist < minDistance) {
minDistance = dist;
bestMatch = car;
}
}
if (!bestMatch) {
this.logger.debug(
`[PriceDrop] findClosestCar: No match found for "${input}" after checking ${validCarsChecked} valid cars out of ${cars.length} total`,
);
} else {
this.logger.debug(
`[PriceDrop] findClosestCar: Best match for "${input}" is "${bestMatch.carName}" with distance ${minDistance}`,
);
}
return bestMatch;
}
private priceDropFormula(
carPrice: number | string,
carModel: number,
carValue: number[],
) {
let normalizePrice: number;
if (typeof carPrice === "string") {
normalizePrice = this.parsePersianNumber(carPrice);
} else if (typeof carPrice === "number") {
normalizePrice = carPrice;
} else {
this.logger.warn(
`Invalid carPrice type received in formula: ${typeof carPrice}`,
);
normalizePrice = 0;
}
const coefficient = this.yearCoefficient[carModel] || 1;
const sumOfSeverity = carValue.reduce((a, c) => a + c, 0);
const totalDrop = (normalizePrice * coefficient * sumOfSeverity) / 400;
return {
carPrice: normalizePrice,
carModel: carModel || null,
carValue: carValue || [],
sumOfSeverity: sumOfSeverity || 0,
coefficientYear: coefficient || 1,
total: totalDrop || 0,
};
}
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
const requestId = request?._id?.toString() || 'unknown';
try {
// Step 1: Check SandHub data
if (!request?.blameFile?.sanHubId) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing sanHubId in blameFile. Cannot calculate price drop.`,
);
return;
}
const sandHubData = await this.sandHubService.getSandHubDataFromId(
request.blameFile.sanHubId,
);
if (!sandHubData) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not retrieve SandHub data for sanHubId: ${request.blameFile.sanHubId}. Cannot calculate price drop.`,
);
return;
}
if (sandHubData.MapTypNam === undefined || sandHubData.MapTypNam === null) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: SandHub data missing ModelCii. Cannot calculate price drop.`,
);
return;
}
const existingPriceDrop = request.priceDrop || {};
const manualOverride = query || {};
// Step 2: Check car parts and get severity values
if (!carPartsAi || (Array.isArray(carPartsAi) && carPartsAi.length === 0)) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No car parts data provided. Cannot calculate severity values.`,
);
}
const severityValues = this.getSeverityValues(carPartsAi);
let autoCalculatedData: any = {};
if (severityValues.length === 0) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: No severity values found from car parts. Price drop calculation will use existing data or manual override only.`,
);
}
// Step 3: Check car detail and find closest car
if (!request?.carDetail) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carDetail in request. Cannot find car price.`,
);
} else if (!request.carDetail.carName) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Missing carName in carDetail. Cannot find car price.`,
);
} else if (severityValues.length > 0) {
// Only fetch car prices if we have severity values to calculate
const carPrices = await this.fetchCarPrices();
if (!carPrices || carPrices.length === 0) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
);
const closestCar = this.findClosestCar(
request.carDetail.carName,
carPrices,
);
if (!closestCar) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Could not find closest matching car for "${request.carDetail.carName}". Available cars: ${carPrices.length}. Cannot determine car price.`,
);
} else if (!closestCar.marketPrice) {
this.logger.warn(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" but marketPrice is missing. Cannot calculate price drop.`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
);
autoCalculatedData = {
carPrice: closestCar.marketPrice,
carValue: severityValues,
};
}
}
}
// Step 4: Merge and recalculate
const finalPriceDropData = this.mergeAndRecalculatePriceDrop(
existingPriceDrop,
autoCalculatedData,
manualOverride,
sandHubData.ModelCii,
);
if (!finalPriceDropData) {
this.logger.error(
`[PriceDrop] Request ${requestId}: Failed to generate final price drop data.`,
);
return;
}
// Step 5: Log calculation result
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}, ` +
`carValue length: ${finalPriceDropData.carValue?.length || 0}, ` +
`total: ${finalPriceDropData.total}`,
);
} else {
this.logger.debug(
`[PriceDrop] Request ${requestId}: Successfully calculated price drop. ` +
`carPrice: ${finalPriceDropData.carPrice}, ` +
`sumOfSeverity: ${finalPriceDropData.sumOfSeverity}, ` +
`total: ${finalPriceDropData.total}`,
);
}
// Step 6: Save to database
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(request._id) },
{ $set: { priceDrop: finalPriceDropData } },
);
this.logger.log(
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
);
} catch (err) {
this.logger.error(
`[PriceDrop] Request ${requestId}: An error occurred during the price drop calculation: ${err.message}`,
err.stack,
);
}
}
private mergeAndRecalculatePriceDrop(
existingData: any,
autoCalculatedData: any,
manualOverride: any,
carModel: number,
) {
const mergedInputs = {
...existingData,
...autoCalculatedData,
...manualOverride,
};
if (!mergedInputs.carPrice || !mergedInputs.carValue) {
return {
carPrice: mergedInputs.carPrice || null,
carModel: carModel,
carValue: mergedInputs.carValue || [],
sumOfSeverity: 0,
coefficientYear: this.yearCoefficient[carModel] || 1,
total: 0,
};
}
return this.priceDropFormula(
mergedInputs.carPrice,
carModel,
mergedInputs.carValue,
);
}
private getSeverityValues(carPartsAi: any[]): number[] {
const severities: number[] = [];
const knownPartKeyMap = new Map<string, string>();
for (const originalKey of Object.keys(this.priceDropPart)) {
if (typeof originalKey === "string") {
knownPartKeyMap.set(this.normalizeKey(originalKey), originalKey);
}
}
for (const part of carPartsAi) {
const damagedPartTable = part?.aiReport?.damaged_part_table;
if (Array.isArray(damagedPartTable) && damagedPartTable.length > 0) {
for (const damagedPartInfo of damagedPartTable) {
if (damagedPartInfo && typeof damagedPartInfo.name === "string") {
const normalizedAiPartName = this.normalizeKey(
damagedPartInfo.name,
);
if (knownPartKeyMap.has(normalizedAiPartName)) {
const originalPriceDropKey =
knownPartKeyMap.get(normalizedAiPartName);
const severityValue =
this.priceDropPart[originalPriceDropKey]?.[
damagedPartInfo.severity
];
if (severityValue !== undefined) {
severities.push(severityValue);
}
}
}
}
}
}
return severities;
}
private async fetchCarPrices(): Promise<
{ carName: string; marketPrice: number }[]
> {
const carPrices: { carName: string; marketPrice: number }[] = [];
const endpoints = ["akharin", "hamrah"];
for (const item of endpoints) {
try {
const url = `${process.env.CW_URL}price?${item}`;
const response = await lastValueFrom(
this.httpService.get(url),
);
if (Array.isArray(response.data)) {
let validEntries = 0;
for (const r of response.data) {
if (r?.carName && r?.marketPrice) {
carPrices.push({
carName: r.carName,
marketPrice: r.marketPrice,
});
validEntries++;
}
}
this.logger.debug(
`[PriceDrop] Fetched ${validEntries} valid car prices from endpoint '${item}' (total entries: ${response.data.length})`,
);
} else {
this.logger.warn(
`[PriceDrop] Endpoint '${item}' returned non-array data: ${typeof response.data}`,
);
}
} catch (err) {
this.logger.error(
`[PriceDrop] Error fetching car prices for endpoint '${item}': ${err.message}`,
err.stack,
);
}
}
if (carPrices.length === 0) {
this.logger.error(
`[PriceDrop] No car prices fetched from any endpoint. Endpoints tried: ${endpoints.join(', ')}`,
);
} else {
this.logger.debug(
`[PriceDrop] Total car prices fetched: ${carPrices.length}`,
);
}
return carPrices;
}
private calculateFinalDropPrice(
closestCar: { marketPrice: number } | null,
carModel: any,
severities: number[],
query?: any,
) {
if (query?.carPrice && query?.carValue) {
return this.priceDropFormula(query.carPrice, carModel, query.carValue);
}
if (closestCar?.marketPrice && severities.length > 0) {
return this.priceDropFormula(
closestCar.marketPrice,
carModel,
severities,
);
}
return null;
}
async requestPerId(requestId: string, currentUser: any, query?: any) {
try {
// 1. Fetch the initial request document and perform calculations
const initialRequest =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!initialRequest) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
await this.calculatePriceDrop(
initialRequest,
initialRequest.imageRequired.selectPartOfCar,
query,
);
const requestUpdated =
await this.claimRequestManagementDbService.findOneDocument(requestId);
await this.populateFactorLinks(requestUpdated.damageExpertReply);
await this.populateFactorLinks(requestUpdated.damageExpertReplyFinal);
// 2. Populate required documents with file links
if (requestUpdated.requiredDocuments && Object.keys(requestUpdated.requiredDocuments).length > 0) {
for (const documentType in requestUpdated.requiredDocuments) {
const documentId = requestUpdated.requiredDocuments[documentType];
if (documentId) {
try {
const doc = await this.claimRequiredDocumentDbService.findById(
documentId.toString(),
);
if (doc && doc.path) {
// Replace ID with file URL
requestUpdated.requiredDocuments[documentType] = buildFileLink(doc.path) as any;
}
} catch (error) {
this.logger.warn(
`Failed to populate required document ${documentType}: ${error.message}`,
);
// Keep the ID if population fails
}
}
}
}
// 3. Populate the resent document and voice links from the nested blameFile.
if (requestUpdated.blameFile?.expertResendReply) {
// Helper function to avoid repeating code
const populatePartyLinks = async (
partyKey: "firstParty" | "secondParty",
) => {
const partyReply =
requestUpdated.blameFile.expertResendReply[partyKey];
if (!partyReply) return;
// Populate the voice link
if (partyReply.voice) {
const voiceDoc = await this.blameVoiceDbService.findOne(
partyReply.voice.toString(),
);
if (voiceDoc) {
partyReply.voice = buildFileLink(voiceDoc.path);
}
}
// Populate the document links
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
// This line will now work correctly after you inject the service.
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) {
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
}
}
}
}
};
// Run the population for both parties
await populatePartyLinks("firstParty");
await populatePartyLinks("secondParty");
}
// 4. Perform authorization checks (as before)
if (this.isCurrentUserAllowed(requestUpdated, currentUser)) {
return new ClaimPerIdRs(requestUpdated);
}
// Check if locked by someone else (allow if locked by current user)
if (this.isRequestLocked(requestUpdated, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// 5. Return the fully populated response
return new ClaimPerIdRs(requestUpdated);
} catch (error) {
this.globalErrHandler(error);
}
}
async imageRequired(requestId, currentUser) {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
if (this.isCurrentUserAllowed(request, currentUser)) {
return (await this.claimRequestManagementDbService.findOne(requestId))
.imageRequired;
} else if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
} else {
throw new HttpException(
{ responseCode: 1008, message: "notAllowed is locked" },
HttpStatus.FORBIDDEN,
);
}
}
private async populateFactorLinks(reply: any): Promise<void> {
if (!reply || !Array.isArray(reply.parts)) {
return;
}
for (const part of reply.parts) {
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
const factorDoc = await this.claimFactorsImageDbService.findById(
part.factorLink.toString(),
);
if (factorDoc) {
part.factorLink = buildFileLink(factorDoc.path);
}
}
}
}
async getRequiredDocuments(requestId: string, currentUser: any) {
try {
const request =
await this.claimRequestManagementDbService.findOneDocument(requestId);
if (!request) {
throw new HttpException(
{ responseCode: 1006, message: "Request not found" },
HttpStatus.NOT_FOUND,
);
}
// Perform authorization checks - allow if locked by current user
if (this.isRequestLocked(request, currentUser)) {
throw new HttpException(
{ responseCode: 1007, message: "Request is locked" },
HttpStatus.FORBIDDEN,
);
}
// Fetch all required documents for this claim
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
requestId,
);
// Populate with file URLs
const populatedDocuments = documents.map((doc) => ({
_id: doc._id,
documentType: doc.documentType,
fileName: doc.fileName,
fileUrl: buildFileLink(doc.path),
uploadedAt: doc.uploadedAt,
}));
return {
claimId: requestId,
totalDocuments: populatedDocuments.length,
documents: populatedDocuments,
};
} catch (error) {
this.globalErrHandler(error);
}
}
private async processAiImages(
imageRequired: any,
): Promise<{ aroundTheCar: any[]; damagePartOfCar: any[] }> {
const aiImages = { aroundTheCar: [], damagePartOfCar: [] };
return aiImages;
}
private async processImage(imageId: string) {
return this.damageImageDbService.findOne(imageId);
}
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
// Check if locked by current user and lock is still active
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub &&
request.lockFile;
if (!isLockedByCurrentUser) {
return false;
}
// Also check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, but still allow access if they were the one who locked it
// The unlockApi will handle the cleanup
return true;
}
}
return true;
}
private isRequestLocked(request: any, currentUser?: any): boolean {
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
return false;
}
// Check if lock has expired
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
// Lock has expired, treat as not locked
return false;
}
}
// If currentUser is provided, allow access if they are the one who locked it
if (currentUser) {
const isLockedByCurrentUser =
String(request?.actorLocked?.actorId) === currentUser.sub;
// Return false (not locked) if locked by current user, true if locked by someone else
return !isLockedByCurrentUser;
}
// If no currentUser provided, treat as locked
return true;
}
async submitReplyRequest(
requestId: string,
reply: ClaimSubmitReplyDto,
userId: any,
) {
try {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (
String(request?.actorLocked?.actorId) !== userId.sub &&
!request?.objection
) {
throw new ForbiddenException("Access denied to this request");
}
// Check if lock has expired (unlockTime has passed)
if (request?.unlockTime && !request?.objection) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
throw new ForbiddenException("Your time has expired");
}
} else if (request?.unlockTime == null && !request?.objection) {
throw new ForbiddenException("Your time has expired");
}
if (!request.lockFile && !request?.objection) {
throw new ForbiddenException(
"For submit reply you must lock the request",
);
}
// Validate total price cap (30,000,000)
if (reply.parts && reply.parts.length > 0) {
const PRICE_CAP = 30000000;
let totalPrice = 0;
for (const part of reply.parts) {
if (part.totalPayment) {
let partPrice: number;
if (typeof part.totalPayment === "string") {
// Handle Persian numbers and remove commas
partPrice = this.parsePersianNumber(part.totalPayment);
} else if (typeof part.totalPayment === "number") {
partPrice = part.totalPayment;
} else {
this.logger.warn(
`Invalid totalPayment type for part ${part.partId}: ${typeof part.totalPayment}`,
);
partPrice = 0;
}
if (isNaN(partPrice)) {
this.logger.warn(
`Could not parse totalPayment for part ${part.partId}: ${part.totalPayment}`,
);
partPrice = 0;
}
totalPrice += partPrice;
}
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `[PRICE_CAP_ERROR] Total price of damaged parts (${totalPrice.toLocaleString()}) exceeds the maximum allowed amount (${PRICE_CAP.toLocaleString()}). Please adjust the prices.`,
error: "PRICE_CAP_ERROR",
code: "PRICE_CAP_ERROR",
totalPrice: totalPrice,
priceCap: PRICE_CAP,
});
}
}
// Validate daghi fields
if (reply.parts && reply.parts.length > 0) {
for (const part of reply.parts) {
if (!part.daghi || !part.daghi.option) {
throw new BadRequestException(
`Daghi option is required for part ${part.partId}`,
);
}
// Validate based on option
if (part.daghi.option === DaghiOption.RECYCLED_PARTS_VALUE) {
if (!part.daghi.price) {
throw new BadRequestException(
`Price is required for part ${part.partId} when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
);
}
} else if (part.daghi.option === DaghiOption.DELIVER_DAMAGED_PART) {
if (!part.daghi.branchId) {
throw new BadRequestException(
`Branch ID is required for part ${part.partId} when option is '${DaghiOption.DELIVER_DAMAGED_PART}'`,
);
}
// Validate branchId is a valid ObjectId
if (!Types.ObjectId.isValid(part.daghi.branchId)) {
throw new BadRequestException(
`Invalid branch ID format for part ${part.partId}`,
);
}
}
// For NO_VALUE and WITH_DAMAGED_PART_CALCULATION, no additional fields needed
}
}
const isObjection = !!request.objection;
const replyFieldKey = isObjection
? "damageExpertReplyFinal"
: "damageExpertReply";
const needsFactorUpload =
reply.parts?.some((part) => part.factorNeeded === true) ?? false;
const nextStatus = needsFactorUpload
? ReqClaimStatus.PendingFactorUpload
: ReqClaimStatus.CheckedRequest;
const nextStep = needsFactorUpload
? ClaimStepsEnum.WaitingForFactorUpload
: ClaimStepsEnum.WaitingForUserToReact;
// Process parts: convert branchId string to ObjectId if present
const processedParts = reply.parts.map((part) => ({
...part,
daghi: {
option: part.daghi.option,
...(part.daghi.price && { price: part.daghi.price }),
...(part.daghi.branchId && {
branchId: new Types.ObjectId(part.daghi.branchId),
}),
},
}));
const replyPayload = {
description: reply.description,
parts: processedParts,
submitTime: new Date(),
actorDetail: {
actorName: userId.fullName,
actorId: userId.sub,
},
};
const updatePayload = {
$set: {
lockFile: false,
claimStatus: nextStatus,
currentStep: nextStep,
[replyFieldKey]: replyPayload,
},
$push: {
actorsChecker: {
[ReqClaimStatus.CheckedRequest]: request.actorLocked,
Date: new Date(),
},
steps: nextStep,
},
};
await this.claimRequestManagementDbService.findAndUpdate(
requestId,
updatePayload,
);
if (!isObjection) {
await this.damageExpertDbService.updateStats(
userId.sub,
"handled",
requestId,
);
}
return {
requestId: request._id,
claimStatus: nextStatus,
};
} catch (error) {
this.logger.error(
`Error in submitReplyRequest for claim ${requestId}:`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
"An internal server error occurred.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async submitResend(
requestId: string,
body: ClaimSubmitResendDto,
userId: any,
) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) throw new NotFoundException("request_not_found");
if (String(request?.actorLocked?.actorId) !== userId)
throw new ForbiddenException("access_denied_please_lock");
if (request.unlockTime == null)
throw new ForbiddenException("time_expired");
if (!request.lockFile)
throw new ForbiddenException(
"for submit reply you must lock the request",
);
if (request.damageExpertResend)
throw new ForbiddenException("request already has resend reply");
if (request.damageExpertReplyFinal)
throw new ForbiddenException("request already has final reply");
await this.claimRequestManagementDbService.findAndUpdate(requestId, {
lockFile: false,
claimStatus: ReqClaimStatus.WaitingForUserToResend,
damageExpertResend: {
resendDescription: body.resendDescription,
resendDocuments: body.resendDocuments,
resendCarParts: body.resendCarParts,
},
$push: {
actorsChecker: {
[ReqClaimStatus.WaitingForUserToResend]: request.actorLocked,
},
Date: new Date(),
},
});
return request;
}
async streamService(requestId, query, res, header) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new NotFoundException("Invalid query type");
}
if (!videoPath) throw new NotFoundException("video_not_found");
const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
if (!existsSync(absolutePath)) {
throw new NotFoundException(
`Video file not found at path: ${absolutePath}`,
);
}
const { size } = statSync(absolutePath);
const range = header.range;
if (range) {
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : size - 1;
const chunkSize = end - start + 1;
const file = createReadStream(absolutePath, { start, end });
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunkSize,
"Content-Type": "video/mp4",
});
file.pipe(res);
} else {
res.writeHead(200, {
"Content-Length": size,
"Content-Type": "video/mp4",
});
createReadStream(absolutePath).pipe(res);
}
}
async getVideoLink(
requestId: string,
query: "car-capture" | "accident",
): Promise<{ url: string }> {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Claim request not found");
}
let videoPath: string = "";
switch (query) {
case "accident":
videoPath = await this.getAccidentVideoPath(
String(
request?.blameFile?.firstPartyDetails?.firstPartyFile
?.firstPartyVideoId,
),
);
break;
case "car-capture":
videoPath = await this.getCarCapture(requestId);
break;
default:
throw new BadRequestException("Invalid query type specified");
}
if (!videoPath) {
throw new NotFoundException(
"Video file not found for the specified type.",
);
}
// Use your existing helper to build the full, public URL
const fileUrl = buildFileLink(videoPath);
// Return the URL in a clean JSON object
return { url: fileUrl };
}
async voiceStream(id, res, headers) {
// audio/mpeg
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) throw new NotFoundException("video_not_found");
const { size } = statSync(voice.path);
const voicePath = voice.path;
const VoiceRange = headers.range;
if (VoiceRange) {
const parts = VoiceRange?.replace(/bytes=/, "").split("-");
const end = parts[1] ? parseInt(parts[1], 16) : size - 1;
const start = parseInt(parts[0], 10);
const chunksize = end - start + 1;
const file = createReadStream(voicePath, { start, end });
const header = {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": chunksize,
"Content-Type": "audio/mpeg",
};
res.writeHead(206, header);
file.pipe(res);
} else {
const head = {
"Content-Length": size,
"Content-Type": "audio/mpeg",
};
res.writeHead(200, head);
createReadStream(voicePath).pipe(res);
}
}
async getVoiceLink(id: string): Promise<{ url: string }> {
const voice = await this.blameVoiceDbService.findOne(id);
if (!voice) {
throw new NotFoundException("Voice file not found");
}
const fileUrl = buildFileLink(voice.path);
return { url: fileUrl };
}
async getCarCapture(requestId): Promise<string> {
const video = await this.claimVideoCaptureDbService.findOne({
claimId: new Types.ObjectId(requestId),
});
if (!video?.path) {
throw new NotFoundException("Car capture video not found");
}
return String(video.path);
}
async getAccidentVideoPath(requestId: string): Promise<string> {
const video = await this.blameVideoDbService.findOneByRequestId(requestId);
if (!video?.path) {
throw new NotFoundException("Accident video not found");
}
return video.path;
}
async validateClaimFactors(
claimId: string,
decisions: {
partId: string;
status: FactorStatus;
rejectionReason?: string;
}[],
expertId: string,
) {
const claim = await this.claimRequestManagementDbService.findOne(claimId);
if (!claim) {
throw new NotFoundException("Claim not found");
}
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
const replyFieldKey = claim.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
if (!finalReply) {
throw new BadRequestException("No expert reply found in this claim.");
}
for (const decision of decisions) {
const partIndex = finalReply.parts.findIndex(
(p) => p.partId === decision.partId,
);
if (partIndex === -1) {
this.logger.warn(
`Part with ID ${decision.partId} not found in claim ${claimId}. Skipping.`,
);
continue;
}
await this.claimRequestManagementDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(claimId) },
{
$set: {
[`${replyFieldKey}.parts.${partIndex}.factorStatus`]:
decision.status,
[`${replyFieldKey}.parts.${partIndex}.rejectionReason`]:
decision.rejectionReason || null,
},
},
);
}
const updatedClaim =
await this.claimRequestManagementDbService.findOne(claimId);
const updatedReply =
updatedClaim.damageExpertReplyFinal || updatedClaim.damageExpertReply;
const requiredFactors = updatedReply.parts.filter((p) => p.factorNeeded);
const anyRejected = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.REJECTED,
);
const anyStillPending = requiredFactors.some(
(p) => p.factorStatus === FactorStatus.PENDING || !p.factorStatus,
);
let newStatus: ReqClaimStatus | null = null;
let responseMessage = "Factor validation complete.";
if (anyRejected) {
// At least one factor is rejected → stay in factor flow and ask user to re-upload
newStatus = ReqClaimStatus.FactorRejected;
responseMessage =
"Factors reviewed. Some factors were rejected, awaiting user resubmission.";
// TODO: Send an SMS or notification to the user here.
} else if (anyStillPending) {
// Some factors still pending (e.g. not validated in this batch)
responseMessage =
"Some factors were approved, but others are still pending validation.";
} else {
// All required factors are approved:
// Move claim into a state where the user can review the final decision and object if needed.
newStatus = ReqClaimStatus.CheckedRequest;
responseMessage =
"All required factors have been approved. The claim is now ready for user review and possible objection.";
}
if (newStatus) {
const update: any = {
$set: { claimStatus: newStatus },
};
// When factors are fully approved, also move the step to WaitingForUserToReact,
// just like the non-factor flow in submitReplyRequest.
if (newStatus === ReqClaimStatus.CheckedRequest) {
update.$set.currentStep = ClaimStepsEnum.WaitingForUserToReact;
update.$set.nextStep = ClaimStepsEnum.WaitingForUserToReact;
update.$push = {
steps: ClaimStepsEnum.WaitingForUserToReact,
};
}
await this.claimRequestManagementDbService.findByIdAndUpdate(
claimId,
update,
);
}
return {
message: responseMessage,
newStatus: newStatus || updatedClaim.claimStatus,
};
}
async inPersonVisit(requestId: string, actorDetail: any) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Blame not found");
}
const updated = await this.claimRequestManagementDbService.findAndUpdate(
requestId,
{
claimStatus: ReqClaimStatus.InPersonVisit,
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
return updated;
}
async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId);
}
private globalErrHandler(error) {
this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus);
}
/**
* V2: Lock a claim request for expert review.
*
* Validations:
* - Claim must exist
* - Status must be WAITING_FOR_DAMAGE_EXPERT
* - Must not already be locked by another expert
*
* On success:
* - Sets workflow.locked = true, workflow.lockedBy = actor
* - Sets status = EXPERT_REVIEWING
* - Sets claimStatus = UNDER_REVIEW
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.locked': true,
'workflow.lockedAt': new Date(),
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
history: {
event: 'CLAIM_LOCKED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Claim locked by damage expert ${actor.fullName}`,
},
},
});
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
/**
* V2: Submit damage expert reply for a claim.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 30,000,000
*
* On success:
* - Stores reply in evaluation.damageExpertReply
* - Unlocks the workflow
* - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION
* - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in a reviewable state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'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');
}
// Price cap validation
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: 'PRICE_CAP_ERROR',
totalPrice,
priceCap: PRICE_CAP,
});
}
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const replyPayload = {
description: reply.description,
parts: reply.parts,
submittedAt: new Date(),
actorDetail: {
actorId: actor.sub,
actorName: actor.fullName,
},
};
const nextStep = needsFactorUpload
? ClaimWorkflowStep.EXPERT_COST_EVALUATION
: ClaimWorkflowStep.INSURER_REVIEW;
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
const nextClaimStatus = needsFactorUpload
? ClaimStatus.NEEDS_REVISION
: ClaimStatus.APPROVED;
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
'workflow.locked': false,
'workflow.currentStep': nextStep,
'workflow.nextStep': needsFactorUpload
? ClaimWorkflowStep.INSURER_REVIEW
: ClaimWorkflowStep.CLAIM_COMPLETED,
'evaluation.damageExpertReply': replyPayload,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
history: {
event: 'EXPERT_REPLY_SUBMITTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
},
},
});
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep: nextStep,
factorNeeded: needsFactorUpload,
};
}
/**
* V2: Expert marks claim for in-person visit.
*
* This is used when the expert decides the user must attend in person
* before a final assessment can be made.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert
* - Status must be EXPERT_REVIEWING
*
* On success:
* - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION
* - Sets evaluation.visitLocation (optional note)
* - Records history event IN_PERSON_VISIT_REQUESTED
* - Unlocks the workflow so user can act
*/
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'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');
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
...(note ? { 'evaluation.visitLocation': note } : {}),
$push: {
history: {
event: 'IN_PERSON_VISIT_REQUESTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: note || 'Expert requested in-person visit',
},
},
});
return {
claimRequestId,
status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: 'In-person visit requested. User will be notified.',
};
}
/**
* V2: Get claim list for damage expert
*
* Returns claims that are:
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
* 3. Any status locked by THIS expert (their own in-progress work)
*/
async getClaimListV2(actorId: string): Promise<GetClaimListV2ResponseDto> {
const claims = await this.claimCaseDbService.find({
$or: [
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
},
],
});
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
status: c.status,
currentStep: c.workflow?.currentStep || '',
locked: c.workflow?.locked || false,
lockedBy: c.workflow?.lockedBy
? {
actorId: c.workflow.lockedBy.actorId?.toString(),
actorName: c.workflow.lockedBy.actorName,
}
: undefined,
vehicle: c.vehicle
? {
carName: c.vehicle.carName,
carModel: c.vehicle.carModel,
carType: c.vehicle.carType,
}
: undefined,
createdAt: c.createdAt,
})) as ClaimListItemV2Dto[];
return { list, total: list.length };
}
/**
* V2: Get claim detail for damage expert
*
* Validations:
* - Claim must exist
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
* - If claim is locked, only the locking expert can access it
*/
async getClaimDetailV2(
claimRequestId: string,
actorId: string,
): Promise<ClaimDetailV2ResponseDto> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
// If locked by someone else, deny access
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actorId
) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
if (requiredDocs) {
const keys =
requiredDocs instanceof Map
? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs);
for (const k of keys as string[]) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
};
}
}
// Build car angles map
const carAnglesData = claim.media?.carAngles as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// Build damaged parts map
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
for (const p of claim.damage?.selectedParts || []) {
const cap = hasCapture(damagedPartsData, p);
damagedParts[p] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
status: claim.status,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy
? {
actorId: claim.workflow.lockedBy.actorId?.toString(),
actorName: claim.workflow.lockedBy.actorName,
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
}
: undefined,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: claim.vehicle,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
}