Files
yara724api/src/claim-request-management/claim-request-management.service.ts
2026-03-14 13:36:05 +03:30

3628 lines
126 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.
import {
BadGatewayException,
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common";
import { Types } from "mongoose";
import { v4 as uuidv4 } from "uuid";
import axios from "axios";
import { unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { AiService } from "src/ai/ai.service";
import { buildFileLink } from "src/helpers/urlCreator";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { CarDamagePartDto } from "./dto/car-part.dto";
import { ClaimPartUploadDetail } from "./dto/claim-detail";
import { CreateClaimRequestDtoRs } from "./dto/create-claim.dto";
import { ClaimRequestDtoRs } from "./dto/claim-rs-dto";
import { ImageRequiredDto } from "./dto/image-required.dto";
import { MyRequestsDto } from "./dto/my-request.dto";
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.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 { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service";
import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.db.service";
import {
ClaimRequestManagementModel,
UserClaimRating,
} from "./entites/schema/claim-request-management.schema";
import { PublicIdService } from "src/utils/public-id/public-id.service";
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserRatingDto } from "./dto/user-rating.dto";
@Injectable()
export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name);
// Authentication URLs and credentials (same as lookups service)
private readonly GET_APP_TOKEN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
private readonly LOGIN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
private readonly FANAVARAN_SUBMIT_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims";
// Authentication credentials
private readonly APP_NAME = "fanhab";
private readonly SECRET = "5Fa@N#A2B";
private readonly USERNAME = "fanhabUser";
private readonly PASSWORD = "Fan#@2U$3er";
// API headers
private readonly CORP_ID = "3539";
private readonly CONTRACT_ID = "263";
private readonly LOCATION = "100";
constructor(
private readonly blameDbService: RequestManagementDbService,
private readonly claimDbService: ClaimRequestManagementDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly carGreenCardDbService: CarGreenCardDbService,
private readonly damageImageDbService: DamageImageDbService,
private readonly userDbService: UserDbService,
private readonly videoCaptureDbService: VideoCaptureDbService,
private readonly claimSignDbService: ClaimSignDbService,
private readonly aiService: AiService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly publicIdService: PublicIdService,
) {}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private userDamageDetail(blRequest: RequestManagementModel) {
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
switch (blRequest.expertSubmitReply.guiltyUserId) {
case first.firstPartyId:
return { isGuilty: first, isNotGuilty: second };
case second.secondPartyId:
return { isGuilty: second, isNotGuilty: first };
}
}
/**
* Helper method to get the effective user ID for claim operations.
* For experts accessing IN_PERSON claim files, returns the claim file's userId.
* For regular users, returns their own ID.
*/
private async getEffectiveUserId(
claimRequestId: string,
actor: any,
): Promise<string> {
// If actor is a user, return their ID
if (actor.role === RoleEnum.USER) {
return actor.sub;
}
// If actor is an expert, verify they're accessing an IN_PERSON claim file
// and return the claim file's userId
const expertRoles = [
RoleEnum.EXPERT,
RoleEnum.DAMAGE_EXPERT,
RoleEnum.FIELD_EXPERT,
];
if (expertRoles.includes(actor.role)) {
const claim = await this.claimDbService.findOne(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim file not found");
}
const blameFile = claim.blameFile;
if (
blameFile?.expertInitiated &&
blameFile?.creationMethod === "IN_PERSON" &&
String(blameFile.initiatedBy) === actor.sub
) {
// Expert is accessing IN_PERSON file they initiated - use claim's userId
return String(claim.userId);
}
throw new ForbiddenException(
"Experts can only access IN_PERSON claim files they initiated",
);
}
throw new ForbiddenException("Invalid role");
}
async createClaimRequest(
requestId: string,
CurrentUserId?: string,
actor?: any,
) {
try {
const blameRequest = await this.blameDbService.findOne(requestId);
if (!blameRequest) {
throw new NotFoundException("Source blame request not found.");
}
// Ensure the blame request has a shared human-friendly id.
// For legacy records, generate it lazily exactly once.
let publicId = (blameRequest as any).publicId as string | undefined;
if (!publicId) {
publicId = await this.publicIdService.generateRequestPublicId();
await this.blameDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{ $set: { publicId } },
);
}
const existingClaimCount = await this.claimDbService.countByFilter({
"blameFile._id": new Types.ObjectId(requestId),
blameRequestId: new Types.ObjectId(requestId),
publicId,
});
if (existingClaimCount > 0) {
throw new ForbiddenException(
"A claim request for this blame file already exists.",
);
}
const isStatusValid =
blameRequest.blameStatus === ReqBlameStatus.CloseRequest;
if (!isStatusValid) {
throw new HttpException(
`Blame request must be 'CloseRequest', but is currently '${blameRequest.blameStatus}'.`,
HttpStatus.BAD_REQUEST,
);
}
if (!blameRequest.expertSubmitReply) {
throw new HttpException(
"Cannot create a claim file until the expert has submitted a reply.",
HttpStatus.GONE,
);
}
// Determine the damaged user (non-guilty party)
const guiltyUserId = String(blameRequest.expertSubmitReply.guiltyUserId);
let damagedUserId: string;
// For IN_PERSON expert-initiated files, expert creates the claim
// For LINK method or regular files, user creates their own claim
const expertRoles = [
RoleEnum.EXPERT,
RoleEnum.DAMAGE_EXPERT,
RoleEnum.FIELD_EXPERT,
];
if (
blameRequest.expertInitiated &&
blameRequest.creationMethod === "IN_PERSON" &&
actor &&
expertRoles.includes(actor.role)
) {
// Expert is creating claim for IN_PERSON file
// Verify expert is the initiating expert
if (String(blameRequest.initiatedBy) !== actor.sub) {
throw new ForbiddenException(
"Only the initiating expert can create claim files for IN_PERSON files.",
);
}
// Determine damaged user (the one who is NOT guilty)
if (
String(blameRequest.firstPartyDetails.firstPartyId) === guiltyUserId
) {
damagedUserId = String(
blameRequest.secondPartyDetails.secondPartyId,
);
} else {
damagedUserId = String(blameRequest.firstPartyDetails.firstPartyId);
}
} else {
// Regular user flow
if (!CurrentUserId) {
throw new BadRequestException("User ID is required.");
}
if (guiltyUserId === CurrentUserId) {
throw new HttpException(
"You cannot submit a claim file as you are the guilty party.",
HttpStatus.FORBIDDEN,
);
}
damagedUserId = CurrentUserId;
}
const userDetail = await this.userDbService.findOne({
_id: new Types.ObjectId(damagedUserId),
});
if (!userDetail) {
throw new NotFoundException("User details could not be found.");
}
const carDetail =
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
? blameRequest.firstPartyDetails.firstPartyCarDetail
: blameRequest.secondPartyDetails.secondPartyCarDetail;
const carPlate =
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
? blameRequest.firstPartyDetails.firstPartyPlate
: blameRequest.secondPartyDetails.secondPartyPlate;
const newClaimPayload = {
blameFile: blameRequest,
userId: new Types.ObjectId(damagedUserId),
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
steps: [ClaimStepsEnum.CreateClaimFile],
currentStep: ClaimStepsEnum.CreateClaimFile,
nextStep: ClaimStepsEnum.SelectDamagePart,
fullName: userDetail.fullName,
carDetail,
carPlate,
userClientKey: new Types.ObjectId(userDetail.clientKey),
createdAt: new Date(),
};
const response = await this.claimDbService.create(newClaimPayload as any);
return new CreateClaimRequestDtoRs(
response["_id"],
"Claim file created successfully.",
);
} catch (er) {
this.logger.error(er);
if (er instanceof HttpException) {
throw er;
}
if (er.code === 11000) {
throw new HttpException(
"Duplicate claim file reference.",
HttpStatus.CONFLICT,
);
}
throw new HttpException(
"An internal server error occurred.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private findTrueItems(userPartSelect: CarDamagePartDto) {
const trueItems = [];
for (const side in userPartSelect) {
for (const parts in userPartSelect[side]) {
if (userPartSelect[side][parts] === true) {
trueItems.push({ side, part: parts });
}
}
}
return trueItems;
}
async setCarPartDamageAndImage(cl, trueItems) {
cl.carPartDamage = trueItems;
const jsonTrueItems = JSON.parse(JSON.stringify(trueItems));
cl.imageRequired = new ImageRequiredModel(jsonTrueItems);
cl.save();
return cl.imageRequired;
}
async selectCarPartDamage(
requestId: string,
userPartSelect: CarDamagePartDto,
actor: any,
): Promise<any> {
try {
const cl = await this.claimDbService.findOne(requestId);
if (!cl) {
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
}
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
if (String(cl.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
if (cl.carPartDamage) {
throw new HttpException(
"car damage part has exist",
HttpStatus.CONFLICT,
);
}
const trueItems = this.findTrueItems(userPartSelect);
const damageParts = this.setCarPartDamageAndImage(cl, trueItems);
await this.claimDbService.findAndUpdate(requestId, {
currentStep: ClaimStepsEnum.SelectDamagePart,
nextStep: ClaimStepsEnum.SelectOtherParts,
$push: {
steps: ClaimStepsEnum.SelectDamagePart,
},
});
return damageParts;
} catch (error) {
this.logger.error(error);
throw error;
}
}
/**
* Field expert completes claim-needed data for expert-initiated IN_PERSON files.
* Only the initiating expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts.
*/
async expertCompleteClaimData(
claimRequestId: string,
expert: any,
dto: {
carPartDamage?: CarDamagePartDto;
sheba?: string;
nationalCodeOfInsurer?: string;
otherParts?: any[];
},
): Promise<{ success: boolean; claimRequestId: string }> {
const claim = await this.claimDbService.findOne(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim file not found");
}
const blameFile = claim.blameFile as any;
if (!blameFile?.expertInitiated || blameFile?.creationMethod !== "IN_PERSON") {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON claim files.",
);
}
if (String(blameFile.initiatedBy) !== expert.sub) {
throw new ForbiddenException(
"Only the initiating field expert can complete claim data for this file.",
);
}
if (dto.carPartDamage) {
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
throw new BadRequestException("Car part damage is already set.");
}
const trueItems = this.findTrueItems(dto.carPartDamage);
const claimDoc = (await this.claimDbService.findOne(
claimRequestId,
)) as any;
if (claimDoc) {
await this.setCarPartDamageAndImage(claimDoc, trueItems);
}
await this.claimDbService.findAndUpdate(claimRequestId, {
currentStep: ClaimStepsEnum.SelectDamagePart,
nextStep: ClaimStepsEnum.SelectOtherParts,
$push: { steps: ClaimStepsEnum.SelectDamagePart },
});
}
const updates: Record<string, any> = {};
if (dto.sheba != null) updates.sheba = dto.sheba;
if (dto.nationalCodeOfInsurer != null)
updates.nationalCodeOfInsurer = dto.nationalCodeOfInsurer;
if (dto.otherParts != null) updates.otherParts = dto.otherParts;
if (Object.keys(updates).length > 0) {
await this.claimDbService.findAndUpdate(claimRequestId, {
$set: updates,
});
}
return { success: true, claimRequestId };
}
async selectCarOtherPartDamage(
requestId: string,
body: any, // Use your DTO for better type safety
file: Express.Multer.File,
actor: any,
) {
try {
// 1. Fetch the claim request and perform initial validation
const claimRequest = await this.claimDbService.findOne(requestId);
if (!claimRequest) {
throw new NotFoundException("Claim file not found");
}
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
if (String(claimRequest.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
if (
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
effectiveUserId
) {
throw new ForbiddenException(
"User access denied. You are the guilty party in the blame file.",
);
}
if (!claimRequest.carPlate) {
throw new BadRequestException(
"Cannot validate ownership: Plate information is missing from the claim file.",
);
}
// await this.sandHubService.getCarOwnershipInfo(
// claimRequest.carPlate,
// body.nationalCodeOfInsurer,
// );
// await this.sandHubService.getShebaValidation(
// body.nationalCodeOfInsurer,
// body.sheba,
// );
const greenCard = await this.carGreenCardDbService.create({
path: file.path,
fileName: file.filename,
claimId: requestId,
});
if (!greenCard) {
throw new BadGatewayException(
"Failed to save car green card document.",
);
}
const updatePayload = {
$set: {
otherParts: JSON.parse(body.otherParts as any),
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
sheba: body.sheba,
carGreenCard: {
path: buildFileLink(greenCard.path),
fileName: greenCard.fileName,
claimId: requestId,
},
currentStep: ClaimStepsEnum.SelectOtherParts,
nextStep: ClaimStepsEnum.ImageRequired,
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
},
$push: {
steps: ClaimStepsEnum.SelectOtherParts,
},
};
this.logger.log(
`[CLAIM] Moving to ImageRequired step for claim ${requestId}. Status set to: ${ReqClaimStatus.WaitingForUserCompleted}`,
);
const updatedClaim = await this.claimDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
// 6. Return the successful response
return new ClaimPartUploadDetail([updatedClaim]);
} catch (error) {
this.logger.error(
`Error in selectCarOtherPartDamage: ${error.message}`,
error.stack,
);
// Re-throw the specific error from the validation steps or a generic one.
throw error;
}
}
/**
* Get the list of required document types based on claim type and whether there's a second party car
* For CAR_BODY type with accident with another object (not a car), only require 7 damaged party documents
* Otherwise, require all 12 documents (7 damaged + 5 guilty party)
*/
private getRequiredDocumentTypes(claimRequest: any): ClaimRequiredDocumentType[] {
const allTypes = Object.values(ClaimRequiredDocumentType);
// Check if it's CAR_BODY type
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
if (isCarBody) {
// Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
const isAccidentWithOtherObject =
(firstPartyCarBody && firstPartyCarBody.object === true) ||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
if (isAccidentWithOtherObject) {
this.logger.debug(
`[getRequiredDocumentTypes] CAR_BODY claim with other object detected. ` +
`Requiring only 7 damaged party documents for claim ${claimRequest._id}`,
);
// Only require damaged party documents (7 documents)
return allTypes.filter((type) => type.startsWith("damaged_"));
}
}
// Default: require all 12 documents
return allTypes;
}
async uploadRequiredDocument(
requestId: string,
documentType: ClaimRequiredDocumentType,
file: Express.Multer.File,
actor: any,
) {
try {
const claimRequest = await this.claimDbService.findOne(requestId);
if (!claimRequest) {
throw new NotFoundException("Claim file not found");
}
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
if (String(claimRequest.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
if (
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
effectiveUserId
) {
throw new ForbiddenException(
"User access denied. You are the guilty party in the blame file.",
);
}
if (claimRequest.currentStep !== ClaimStepsEnum.UploadRequiredDocuments) {
throw new BadRequestException(
`Claim is not in the required documents upload step. Current step: ${claimRequest.currentStep}`,
);
}
if (!file) {
throw new BadRequestException("File is required");
}
// Check if this document type has already been uploaded
const existingDocuments = await this.claimRequiredDocumentDbService.findAll({
claimId: new Types.ObjectId(requestId),
documentType: documentType,
});
// If document exists, delete the old one first (allow replacement)
if (existingDocuments.length > 0) {
for (const existingDoc of existingDocuments) {
// Delete the file from filesystem
try {
if (existingDoc.path && existsSync(existingDoc.path)) {
await unlink(existingDoc.path);
this.logger.log(
`Deleted old file: ${existingDoc.path} for document type ${documentType}`,
);
}
} catch (fileError) {
this.logger.warn(
`Failed to delete file ${existingDoc.path}: ${fileError.message}`,
);
// Continue even if file deletion fails
}
// Delete from the separate collection
await this.claimRequiredDocumentDbService.delete(existingDoc._id.toString());
// Remove from claim's requiredDocuments map
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$unset: {
[`requiredDocuments.${documentType}`]: "",
},
},
);
}
}
// Create the document record
const documentRecord = await this.claimRequiredDocumentDbService.create({
path: file.path,
fileName: file.filename,
claimId: new Types.ObjectId(requestId),
documentType: documentType,
uploadedAt: new Date(),
});
// Add document ID to claim's requiredDocuments map using documentType as key
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$set: {
[`requiredDocuments.${documentType}`]: documentRecord._id,
},
},
);
// Check if all required documents have been uploaded
// For CAR_BODY with other object, only require 7 documents instead of 12
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({
claimId: new Types.ObjectId(requestId),
});
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
const allUploaded = allRequiredTypes.every((type) =>
uploadedTypes.includes(type),
);
// If all documents are uploaded, move to next step
if (allUploaded) {
await this.claimDbService.findAndUpdate(requestId, {
$set: {
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
nextStep: ClaimStepsEnum.waitForDamageExpertComment,
claimStatus: ReqClaimStatus.UnChecked,
},
$push: {
steps: ClaimStepsEnum.UploadRequiredDocuments,
},
});
} else {
// Ensure status is set when entering this step (first document upload)
const currentClaim = await this.claimDbService.findOne(requestId);
if (currentClaim?.claimStatus !== ReqClaimStatus.UploadingRequiredDocuments) {
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$set: {
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
},
},
);
}
}
return {
message: "Document uploaded successfully",
documentId: documentRecord._id,
documentType: documentType,
allDocumentsUploaded: allUploaded,
nextStep: allUploaded ? ClaimStepsEnum.waitForDamageExpertComment : null,
};
} catch (error) {
this.logger.error(
`Error in uploadRequiredDocument: ${error.message}`,
error.stack,
);
throw error;
}
}
async getRequiredDocumentsStatus(requestId: string) {
try {
const claimRequest = await this.claimDbService.findOne(requestId);
if (!claimRequest) {
throw new NotFoundException("Claim file not found");
}
// Get required document types based on claim type
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({
claimId: new Types.ObjectId(requestId),
});
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
const status = allRequiredTypes.map((type) => {
const doc = uploadedDocuments.find((d) => d.documentType === type);
return {
documentType: type,
uploaded: !!doc,
documentId: doc?._id || null,
fileName: doc?.fileName || null,
uploadedAt: doc?.uploadedAt || null,
};
});
return {
claimId: requestId,
currentStep: claimRequest.currentStep,
nextStep: claimRequest.nextStep,
allUploaded: allRequiredTypes.every((type) => uploadedTypes.includes(type)),
documents: status,
totalRequired: allRequiredTypes.length,
totalUploaded: uploadedDocuments.length,
};
} catch (error) {
this.logger.error(
`Error in getRequiredDocumentsStatus: ${error.message}`,
error.stack,
);
throw error;
}
}
async getImageRequiredList(requestId: string) {
return await new Promise((resolve, reject) => {
this.claimDbService
.findOne(requestId)
.then((cl) => {
if (!cl) {
throw new HttpException(
"Claim File Not Found",
HttpStatus.NOT_FOUND,
);
}
// Allow access if:
// 1. Currently in ImageRequired step (currentStep or nextStep is ImageRequired)
// 2. OR has completed ImageRequired and moved to next step (to allow viewing data after completion)
const isInImageRequiredStep =
cl.currentStep === ClaimStepsEnum.ImageRequired ||
cl.nextStep === ClaimStepsEnum.ImageRequired;
const hasCompletedImageRequired =
cl.steps &&
Array.isArray(cl.steps) &&
cl.steps.includes(ClaimStepsEnum.ImageRequired);
if (isInImageRequiredStep || hasCompletedImageRequired) {
this.logger.debug(
`[getImageRequiredList] Allowing access for claim ${requestId}. ` +
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
`hasCompletedImageRequired: ${hasCompletedImageRequired}`,
);
if (
cl.imageRequired.aroundTheCar &&
Array.isArray(cl.imageRequired.aroundTheCar)
) {
cl.imageRequired.aroundTheCar.forEach((carPart) => {
//@ts-ignore
delete carPart?.aiReport;
});
cl.imageRequired.selectPartOfCar.forEach((part) => {
delete part.aiReport;
});
}
resolve(new ImageRequiredDto(cl));
} else {
this.logger.warn(
`[getImageRequiredList] Access denied for claim ${requestId}. ` +
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
`steps: ${JSON.stringify(cl.steps)}`,
);
throw new HttpException(
"This File is Not in ImageRequired Step",
HttpStatus.FORBIDDEN,
);
}
})
.catch((er) => {
this.logger.error(er);
if (er instanceof Error) {
reject(er);
} else {
reject(new Error(`Promise rejected with non-error value: ${er}`));
}
});
});
}
async setImageRequiredList(
requestId: string,
partOfDamageId: string,
fileDetail: any,
) {
try {
const document = await this.claimDbService.findOneDocument(requestId);
if (!document) {
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
}
let result = [];
const imageRequiredKeys = Object.keys(document.imageRequired);
if (partOfDamageId === undefined) {
if (document.videoCaptureId) {
throw new HttpException("Video already exists", HttpStatus.CONFLICT);
}
const videoId = await this.createVideoCapture(fileDetail, requestId);
await this.updateClaimWithVideoCapture(requestId, videoId);
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
} else if (partOfDamageId) {
await this.processDamageImages(
document,
requestId,
partOfDamageId,
fileDetail,
imageRequiredKeys,
result,
);
result.forEach((item) => {
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
item.aroundTheCar.forEach((carPart) => {
delete carPart.aiReport;
});
item.selectPartOfCar.forEach((part) => {
delete part.aiReport;
});
}
});
}
return result;
} catch (error) {
this.handleError(error);
}
}
async setDamageImage(
requestId: string,
partOfDamageId: string,
fileDetail: Express.Multer.File,
) {
try {
const document = await this.claimDbService.findOneDocument(requestId);
if (!document) {
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
}
if (!fileDetail) {
throw new BadRequestException("Image file is required.");
}
if (
document.currentStep !== ClaimStepsEnum.ImageRequired &&
document.nextStep !== ClaimStepsEnum.ImageRequired
) {
throw new BadRequestException(
`Claim is not in the ImageRequired step. Current step: ${document.currentStep}, Status: ${document.claimStatus}`,
);
}
const result = [];
const imageRequiredKeys = Object.keys(document.imageRequired);
// Process images - AI will be processed in background
await this.processDamageImages(
document,
requestId,
partOfDamageId,
fileDetail,
imageRequiredKeys,
result,
);
// Clean up the response - remove AI reports since they're processing in background
result.forEach((item) => {
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
item.aroundTheCar.forEach((carPart) => delete carPart.aiReport);
item.selectPartOfCar.forEach((part) => delete part.aiReport);
}
});
this.logger.log(
`[setDamageImage] Image uploaded successfully for part ${partOfDamageId}, request ${requestId}. AI processing started in background.`,
);
return result;
} catch (error) {
this.handleError(error);
}
}
async setVideoCapture(requestId: string, fileDetail: Express.Multer.File) {
try {
const document = await this.claimDbService.findOneDocument(requestId);
if (!document) {
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
}
if (!fileDetail) {
throw new BadRequestException("Video file is required.");
}
if (document.videoCaptureId) {
throw new HttpException(
"A video has already been uploaded for this claim.",
HttpStatus.CONFLICT,
);
}
const videoId = await this.createVideoCapture(fileDetail, requestId);
await this.updateClaimWithVideoCapture(requestId, videoId);
return {
statusCode: HttpStatus.ACCEPTED,
message: "Video capture uploaded successfully.",
videoId: videoId,
};
} catch (error) {
this.handleError(error);
}
}
private async createVideoCapture(fileDetail: any, requestId: string) {
const videoId = await this.videoCaptureDbService.create({
path: fileDetail.path,
filName: fileDetail.filename,
claimId: new Types.ObjectId(requestId),
});
return videoId;
}
private async updateClaimWithVideoCapture(requestId: string, videoId: any) {
if (
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$set: { videoCaptureId: videoId },
},
)
) {
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
}
}
private async processAiRequestQueue(
tasks: (() => Promise<void>)[],
delayMs = 200,
): Promise<void> {
for (const task of tasks) {
await task();
await this.delay(delayMs);
}
}
/**
* Process AI requests in the background without blocking the response
* This allows users to upload images quickly without waiting for AI processing
*/
private async processAiRequestQueueInBackground(
tasks: (() => Promise<void>)[],
requestId: string,
partId: string,
): Promise<void> {
if (tasks.length === 0) {
return;
}
this.logger.log(
`[BACKGROUND AI] Starting background AI processing for ${tasks.length} image(s), part ${partId}, request ${requestId}`,
);
// Process in background without blocking
setImmediate(async () => {
try {
await this.processAiRequestQueue(tasks);
this.logger.log(
`[BACKGROUND AI] Completed AI processing for part ${partId}, request ${requestId}`,
);
} catch (error) {
this.logger.error(
`[BACKGROUND AI] Failed to process AI requests for part ${partId}, request ${requestId}: ${error.message}`,
error.stack,
);
// Don't throw - we're in background processing
}
});
}
private async handleAiSuccess(
response: any,
key: string,
partId: string,
requestId: string,
): Promise<void> {
this.logger.log(`[STEP 4/4] Processing AI response for part ${partId}, requestId: ${requestId}`);
// 1. Validate that we have the processed image
if (!response?.downloadLink) {
this.logger.error(`[ERROR] Missing processed image (downloadLink) in AI response`);
this.logger.error(`[ERROR] Part ID: ${partId}, Request ID: ${requestId}`);
this.logger.error(`[ERROR] Response keys: ${JSON.stringify(response ? Object.keys(response) : 'null')}`);
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response, null, 2)}`);
throw new HttpException(
`AI response missing processed image (downloadLink) for part ${partId}`,
HttpStatus.BAD_GATEWAY,
);
}
this.logger.log(`[STEP 4/4] Processed image URL: ${response.downloadLink}`);
// 2. Safely access the nested properties.
const reports = response?.reports;
const reportText = reports?.distinct_damaged_parts_report?.report;
// 3. Log warning if reports are missing but continue with image
if (!reports) {
this.logger.warn(
`[WARNING] AI response for part ${partId} is missing the 'reports' object, but downloadLink exists.`,
);
this.logger.warn(`[WARNING] Will save image but without AI report data.`);
} else {
this.logger.log(`[STEP 4/4] AI reports present: ${reports ? 'yes' : 'no'}`);
}
// 4. Build the update payload.
const updatePayload = {
$set: {
[`imageRequired.${key}.$[elem].aiReport`]: reports || {},
[`imageRequired.${key}.$[elem].aiImage`]: response.downloadLink,
[`imageRequired.${key}.$[elem].aiReportText`]: reportText || "",
[`imageRequired.aiReportText`]: response.reportsText || "",
},
};
// 5. Update the database using arrayFilters.
try {
const updateResult = await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
updatePayload,
{
arrayFilters: [{ "elem.partId": partId }],
},
);
if (!updateResult) {
this.logger.error(`[ERROR] Database update failed - no document found or update failed`);
this.logger.error(`[ERROR] Request ID: ${requestId}, Part ID: ${partId}, Key: ${key}`);
throw new HttpException(
`Failed to update database with AI results for part ${partId}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
this.logger.log(`[SUCCESS] Successfully updated AI report and image for part ${partId}`);
} catch (dbError) {
this.logger.error(`[ERROR] Database update error for part ${partId}:`);
this.logger.error(`[ERROR] ${dbError.message}`);
this.logger.error(`[ERROR] Stack: ${dbError.stack}`);
throw new HttpException(
`Database update failed: ${dbError.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async retryAiRequest(
newImageDoc: any,
key: string,
partId: string,
requestId: string,
retries = 3,
): Promise<void> {
const attemptNumber = 4 - retries; // Track which attempt this is
this.logger.log(`[ATTEMPT ${attemptNumber}] Processing AI request for part ${partId}, requestId: ${requestId}`);
this.logger.log(`[ATTEMPT ${attemptNumber}] File path: ${newImageDoc.path}, File name: ${newImageDoc.fileName}`);
try {
// Use the new, robust AiService to send the request.
// Pass both path and fileName to the AI service
const response = await this.aiService.aiRequestImage({
path: newImageDoc.path,
fileName: newImageDoc.fileName,
});
// If the request succeeds, handle the success.
await this.handleAiSuccess(response, key, partId, requestId);
this.logger.log(`[SUCCESS] AI processing completed successfully for part ${partId}`);
} catch (error) {
// Extract error source from error message if available
const errorMessage = error.message || String(error);
const errorSource = errorMessage.includes('[ERROR]')
? errorMessage.split('[ERROR]')[1]?.split(']')[0] || 'UNKNOWN'
: 'UNKNOWN';
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Error source: ${errorSource}`);
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Part ID: ${partId}, Request ID: ${requestId}`);
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Error: ${errorMessage}`);
if (retries > 0) {
this.logger.warn(
`⚠️ Retrying AI request for part ${partId}... Attempts left: ${retries - 1}`,
);
await this.delay(1000); // Wait before retrying
return this.retryAiRequest(
newImageDoc,
key,
partId,
requestId,
retries - 1,
);
} else {
this.logger.error(`🔴 [FINAL FAILURE] AI request failed after all retries`);
this.logger.error(`🔴 [FINAL FAILURE] Request ID: ${requestId}`);
this.logger.error(`🔴 [FINAL FAILURE] Part ID: ${partId}`);
this.logger.error(`🔴 [FINAL FAILURE] File path: ${newImageDoc.path}`);
this.logger.error(`🔴 [FINAL FAILURE] Error source: ${errorSource}`);
this.logger.error(`🔴 [FINAL FAILURE] Final error: ${errorMessage}`);
if (error.stack) {
this.logger.error(`🔴 [FINAL FAILURE] Stack trace: ${error.stack}`);
}
// Optional: Update the claim status to indicate an AI failure.
// You could throw here if you want to fail the entire operation
throw error; // Re-throw to propagate the error
}
}
}
private async processDamageImages(
document: any,
requestId: string,
partOfDamageId: string,
fileDetail: any,
imageRequiredKeys: string[],
result: any[],
) {
const aiRequestQueue = [];
for (const key of imageRequiredKeys) {
const images = document.imageRequired[key];
for (const img of images) {
if (img.partId === partOfDamageId) {
const newImageDoc = await this.createDamageImage(
fileDetail,
requestId,
);
// Queue AI request for background processing
const aiRequestTask = () =>
this.retryAiRequest(newImageDoc, key, partOfDamageId, requestId);
aiRequestQueue.push(aiRequestTask);
// Update the document immediately with the uploaded image
await this.updateImageRequiredDocument(
requestId,
key,
partOfDamageId,
newImageDoc,
);
const updatedDocs =
await this.claimDbService.findOneDocument(requestId);
this.handleImageUpdateResult(result, updatedDocs, requestId);
}
}
}
// Process AI requests in the background (fire and forget)
// Don't await - let it run asynchronously so user doesn't have to wait
this.processAiRequestQueueInBackground(aiRequestQueue, requestId, partOfDamageId)
.catch((error) => {
// Log errors but don't fail the upload
this.logger.error(
`[BACKGROUND AI] Error processing AI requests for part ${partOfDamageId}, request ${requestId}: ${error.message}`,
error.stack,
);
});
}
private async updateImageRequiredDocument(
requestId,
key,
partOfDamageId,
newImageDoc,
) {
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$set: {
[`imageRequired.${key}.$[elem].upload`]: true,
[`imageRequired.${key}.$[elem].imageId`]: newImageDoc._id,
},
},
{
arrayFilters: [{ "elem.partId": partOfDamageId }],
},
);
}
private async createDamageImage(fileDetail, requestId) {
return await this.damageImageDbService.create({
path: fileDetail.path,
fileName: fileDetail.filename,
claimId: requestId,
});
}
private async handleImageUpdateResult(result, updatedDocs, requestId) {
const aroundCarChecked = updatedDocs.imageRequired.aroundTheCar.filter(
(r) => !r.upload,
);
const selectPartOfCar = updatedDocs.imageRequired.selectPartOfCar.filter(
(r) => !r.upload,
);
result.push(updatedDocs.imageRequired);
if (aroundCarChecked.length === 0 && selectPartOfCar.length === 0) {
await this.claimDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
$push: {
steps: ClaimStepsEnum.ImageRequired,
},
$set: {
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
},
},
);
}
}
async myRequests(currentUser) {
// For users, return their own claim files
if (currentUser.role === RoleEnum.USER) {
const claimFile = await this.claimDbService.findAllByAnyFilter({
userId: new Types.ObjectId(currentUser.sub),
});
if (!claimFile) throw new NotFoundException("claim file not found");
return new MyRequestsDto(claimFile);
}
// For experts, return IN_PERSON claim files they initiated
const expertRoles = [
RoleEnum.EXPERT,
RoleEnum.DAMAGE_EXPERT,
RoleEnum.FIELD_EXPERT,
];
if (expertRoles.includes(currentUser.role)) {
const allClaims = await this.claimDbService.findAllByAnyFilter({});
const expertClaims = allClaims.filter((claim) => {
const blameFile = claim.blameFile;
return (
blameFile?.expertInitiated &&
blameFile?.creationMethod === "IN_PERSON" &&
String(blameFile.initiatedBy) === currentUser.sub
);
});
if (expertClaims.length === 0) {
throw new NotFoundException("No IN_PERSON claim files found");
}
return new MyRequestsDto(expertClaims);
}
throw new ForbiddenException("Invalid role");
}
private waitingForUserResendRS(req: ClaimRequestManagementModel) {
if (req.damageExpertResend) {
return new ClaimRequestDtoRs(
req.damageExpertResend as any,
"این درخواست نیاز به ارسال مدارک مجدد دارد ",
ReqClaimStatus.WaitingForUserToResend,
);
} else {
throw new ForbiddenException("damageExpertResend doesnt exist");
}
}
private closeRequestRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"این درخواست بسته شده است ",
ReqClaimStatus.CloseRequest,
);
}
private checkAgainRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"در انتظار پاسخ مجدد کارشناس",
ReqClaimStatus.CheckAgain,
);
}
private userPendingRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"این پرونده از طرف شما کامل نشده میباشد",
ReqClaimStatus.UserPending,
);
}
private reviewRequestRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
`درخواست درحال برسی توسط کارشناس میباشد ${req.unlockTime} - زمان باقی مانده لطفا صبر کنید`,
ReqClaimStatus.ReviewRequest,
);
}
private checkedRequestRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"این درخواست بررسی شده است",
ReqClaimStatus.CheckedRequest,
);
}
private uncheckedRequestRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"این درخواست در انتظار بررسی می باشد.",
ReqClaimStatus.UnChecked,
);
}
private waitingForUserCompletedRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"این درخواست نیازمند تکمیل توسط کاربر می باشد.",
ReqClaimStatus.WaitingForUserCompleted,
);
}
private inPersonVisitRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"مراجعه حضوری",
ReqClaimStatus.InPersonVisit,
);
}
private responseController(request: ClaimRequestManagementModel) {
switch (request.claimStatus) {
case ReqClaimStatus.WaitingForUserCompleted:
return this.waitingForUserCompletedRS(request);
case ReqClaimStatus.UnChecked:
return this.uncheckedRequestRS(request);
case ReqClaimStatus.CheckedRequest:
return this.checkedRequestRS(request);
case ReqClaimStatus.ReviewRequest:
return this.reviewRequestRS(request);
case ReqClaimStatus.UserPending:
return this.userPendingRS(request);
case ReqClaimStatus.CloseRequest:
return this.closeRequestRS(request);
case ReqClaimStatus.WaitingForUserToResend:
return this.waitingForUserResendRS(request);
case ReqClaimStatus.CheckAgain:
return this.checkAgainRS(request);
case ReqClaimStatus.InPersonVisit:
return this.inPersonVisitRS(request);
case ReqClaimStatus.PendingFactorValidation:
return this.pendingFactorValidationRS(request);
case ReqClaimStatus.FactorRejected:
return this.factorRejectedRS(request);
case ReqClaimStatus.PendingFactorUpload:
return this.pendingFactorUploadRS(request);
case ReqClaimStatus.UploadingRequiredDocuments:
return this.uploadingRequiredDocumentsRS(request);
default:
return "Unknown status";
}
}
private pendingFactorUploadRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"منتظر آپلود فاکتور توسط کاربر",
ReqClaimStatus.PendingFactorUpload,
);
}
private pendingFactorValidationRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"منتظر تایید فاکتور توسط کارشناس",
ReqClaimStatus.PendingFactorValidation,
);
}
private factorRejectedRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"فاکتور ریجکت شده و باید دوباره ارسال شود",
ReqClaimStatus.FactorRejected,
);
}
private uploadingRequiredDocumentsRS(req: ClaimRequestManagementModel) {
return new ClaimRequestDtoRs(
req,
"منتظر آپلود مدارک مورد نیاز توسط کاربر",
ReqClaimStatus.UploadingRequiredDocuments,
);
}
async requestDetails(requestId: string, user?: any) {
const request = await this.claimDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Claim request not found");
}
// Verify access if user is provided
if (user) {
const effectiveUserId = await this.getEffectiveUserId(requestId, user);
if (String(request.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
}
if (request.imageRequired) {
request.imageRequired.selectPartOfCar.forEach((r) => {
delete r.aiReport;
});
request.imageRequired.aroundTheCar.forEach((r) => {
//@ts-ignore
delete r.aiReport;
});
}
// Populate factorLink ObjectIds with actual file URLs
await this.populateFactorLinks(request.damageExpertReply);
await this.populateFactorLinks(request.damageExpertReplyFinal);
return this.responseController(request);
}
/**
* Converts factorLink ObjectIds to file URLs
*/
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 submitUserReply(
requestId: string,
body: UserCommentDto,
file?: Express.Multer.File,
actor?: any,
) {
try {
const request = await this.claimDbService.findOne(requestId);
if (!this.isValidRequest(request)) {
throw new HttpException("Invalid request", HttpStatus.NOT_ACCEPTABLE);
}
// Verify access if actor is provided
if (actor) {
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
if (String(request.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
}
const finalReply =
request.damageExpertReplyFinal || request.damageExpertReply;
const replyFieldKey = request.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
const parts = finalReply?.parts || [];
const missingFactors = parts
.filter((p) => p.factorNeeded === true && !p.factorLink)
.map((p) => p.carPartDamage || p.partId);
if (missingFactors.length > 0) {
throw new HttpException(
`Missing factor images for parts: ${missingFactors.join(", ")}`,
HttpStatus.BAD_REQUEST,
);
}
const userComment = {
...body,
...(file ? { fileLink: buildFileLink(file.path) } : {}),
};
await this.claimDbService.findAndUpdate(requestId, {
$set: {
[`${replyFieldKey}.userComment`]: {
...userComment,
time: Date.now(),
},
},
});
if (body.isAccept) {
const { partsFactorDetail, partsNeedFactor } =
await this.processAcceptance(request, file, requestId, body);
await this.claimDbService.findAndUpdate(requestId, {
$set: {
claimStatus: ReqClaimStatus.CloseRequest,
},
});
return new SubmitUserReplyDtoRs(
request,
partsFactorDetail,
partsNeedFactor,
);
} else {
this.handleRejection(request, body.isAccept, requestId);
return { requestId, isAccept: body.isAccept };
}
} catch (error) {
this.handleError(error);
}
}
private isValidRequest(request: any): boolean {
return (
request.damageExpertReply &&
request.claimStatus === ReqClaimStatus.CheckedRequest
);
}
private async processAcceptance(
request: any,
file: any,
requestId: string,
body: UserCommentDto,
): Promise<{ partsFactorDetail: any[]; partsNeedFactor: boolean }> {
const partsFactorDetail = [];
let partsNeedFactor = false;
for (let i = 0; i < request.damageExpertReply.parts.length; i++) {
const part = request.damageExpertReply.parts[i];
if (!part || part.partCost === undefined) continue;
if (part.partCost === "FACTOR") {
partsNeedFactor = await this.handleFactorPart(
requestId,
i,
part,
partsFactorDetail,
partsNeedFactor,
);
} else {
await this.handleNonFactorPart(requestId, file, body.isAccept, part);
}
}
return { partsFactorDetail, partsNeedFactor };
}
private async handleFactorPart(
requestId: string,
i: number,
part: any,
partsFactorDetail: any[],
partsNeedFactor: boolean,
): Promise<boolean> {
if (part.needFactor === undefined) {
const uId = uuidv4();
partsNeedFactor = true;
await this.claimDbService.findAndUpdate(requestId, {
$set: {
[`damageExpertReply.parts.${i}.needFactor`]: true,
[`damageExpertReply.parts.${i}.partId`]: uId,
},
});
partsFactorDetail.push({ partId: uId, ...part });
} else {
partsFactorDetail.push(part);
}
return partsNeedFactor;
}
private async handleNonFactorPart(
requestId: string,
file: any,
isAccept: boolean,
part: any,
): Promise<void> {
const sign = await this.claimSignDbService.create(file);
await this.claimDbService.findAndUpdate(requestId, {
$set: {
"damageExpertReply.userComment.isAccept": isAccept,
"damageExpertReply.userComment.signId": sign["_id"],
},
});
}
private async handleRejection(
request: any,
isAccept: boolean,
requestId: string,
): Promise<void> {
await this.claimDbService.findAndUpdate(requestId, {
$set: {
"damageExpertReply.userComment.isAccept": isAccept,
"damageExpertReply.userComment.signId": null,
},
});
}
/**
* Allows the damaged user to rate their claim file after completion.
* Only the claim owner (damaged user) can submit this rating and only
* when the claim is in CloseRequest status.
*/
async addUserRating(
claimRequestId: string,
actor: any,
ratingDto: UserRatingDto,
): Promise<UserClaimRating> {
const claim = await this.claimDbService.findOne(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim file not found");
}
// Only the damaged user (claim owner) can rate the claim
const actorId = actor?.sub;
if (!actorId || String(claim.userId) !== actorId) {
throw new ForbiddenException(
"Only the claim owner can submit a satisfaction rating.",
);
}
// Rating is only allowed after the claim is fully closed
if (claim.claimStatus !== ReqClaimStatus.CloseRequest) {
throw new BadRequestException(
"You can only rate a claim after it has been closed.",
);
}
const userRating: UserClaimRating = {
progressSpeed: ratingDto.progressSpeed,
registrationEase: ratingDto.registrationEase,
overallEvaluation: ratingDto.overallEvaluation,
comment: ratingDto.comment,
createdAt: new Date(),
};
await this.claimDbService.findAndUpdate(claimRequestId, {
$set: { userRating },
});
return userRating;
}
async uploadClaimFactor(
claimId: string,
partId: string,
file: Express.Multer.File,
actor: any,
) {
try {
if (!file) {
throw new BadRequestException("A factor file is required for upload.");
}
const claim = await this.claimRequestManagementDbService.findOne(claimId);
if (!claim) {
throw new NotFoundException("Claim not found");
}
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
if (String(claim.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
const finalReply =
claim.damageExpertReplyFinal || claim.damageExpertReply;
const replyFieldKey = claim.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
if (!finalReply) {
throw new BadRequestException("No expert reply found in this claim.");
}
const part = finalReply.parts?.find((p) => p.partId === String(partId));
if (!part) {
throw new BadRequestException(
"The specified part was not found in the expert's reply.",
);
}
const factorRecord = await this.claimFactorsImageDbService.create({
claimId,
partId,
partName: part.carPartDamage,
path: file.path,
fileName: file.filename,
uploadedAt: new Date(),
});
const updatedClaim =
await this.claimRequestManagementDbService.findOneAndUpdate(
{
_id: new Types.ObjectId(claimId),
[`${replyFieldKey}.parts.partId`]: partId,
},
{
$set: {
[`${replyFieldKey}.parts.$.factorLink`]: factorRecord._id,
[`${replyFieldKey}.parts.$.factorStatus`]: FactorStatus.PENDING,
},
},
);
if (!updatedClaim) {
throw new NotFoundException(
"Could not find the specific part to update in the database.",
);
}
await this.checkAndTransitionStatusAfterUpload(claimId);
return {
message: "Factor uploaded successfully. Awaiting expert validation.",
factorId: factorRecord._id,
url: buildFileLink(file.path),
};
} catch (error) {
this.logger.error(
`Error during factor upload for claim ${claimId}:`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
"An internal server error occurred during factor upload.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async checkAndTransitionStatusAfterUpload(
claimId: string,
): Promise<void> {
// Re-fetch the latest state of the claim
const claim = await this.claimRequestManagementDbService.findOne(claimId);
const finalReply = claim.damageExpertReplyFinal || claim.damageExpertReply;
if (!finalReply?.parts) {
return;
}
const requiredFactorParts = finalReply.parts.filter(
(p) => p.factorNeeded === true,
);
if (requiredFactorParts.length === 0) {
return;
}
const allFactorsAreUploaded = requiredFactorParts.every(
(p) => !!p.factorLink,
);
if (allFactorsAreUploaded) {
this.logger.log(
`All required factors for claim ${claimId} have been uploaded. Updating status.`,
);
await this.claimRequestManagementDbService.findByIdAndUpdate(claimId, {
$set: {
claimStatus: ReqClaimStatus.PendingFactorValidation,
},
});
}
}
private async resendDocumentsController(claimFile, query, file) {
if (!Object.keys(query).includes("documentName")) {
throw new HttpException("use documentName", HttpStatus.BAD_REQUEST);
}
const fileRequired = claimFile.damageExpertResend.resendDocuments;
if (!fileRequired.includes(query.documentName)) {
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
}
try {
const updated = await this.claimRequestManagementDbService.findAndUpdate(
String(claimFile._id),
{
$push: {
"userResendDocuments.documents": {
[query.documentName]: buildFileLink(file.path),
},
},
},
{ new: true },
);
const existingKeys = updated.userResendDocuments.documents.flatMap(
Object.keys,
);
const allExist = fileRequired.every((key) => existingKeys.includes(key));
if (allExist) {
await this.claimRequestManagementDbService.findAndUpdate(
String(claimFile._id),
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
);
}
return {
allExist,
updated: updated.userResendDocuments.documents,
fileRequired,
};
} catch (err) {
this.logger.error(err);
return new HttpException(err.message, err.status);
}
}
private async resendCarParts(claimFile, query, file) {
if (!Object.keys(query).includes("partId")) {
throw new HttpException(
"use resendCarParts query string",
HttpStatus.BAD_REQUEST,
);
}
if (!Object.keys(query).includes("side")) {
throw new HttpException(
"side is required for car parts",
HttpStatus.BAD_REQUEST,
);
}
const fileRequired = claimFile.damageExpertResend.resendCarParts.map(
(r) => r.partId,
);
if (!fileRequired.includes(query.partId)) {
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
}
try {
const fileEntry = {
[query.partId]: {
link: buildFileLink(file.path),
side: query.side,
},
};
const updated = await this.claimRequestManagementDbService.findAndUpdate(
String(claimFile._id),
{ $push: { "userResendDocuments.carParts": fileEntry } },
{ new: true },
);
const existingKeys = updated.userResendDocuments.carParts.flatMap(
Object.keys,
);
const allExist = fileRequired.every((key) => existingKeys.includes(key));
if (allExist) {
await this.claimRequestManagementDbService.findAndUpdate(
String(claimFile._id),
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
);
}
return {
allExist,
updated: updated.userResendDocuments.carParts,
fileRequired,
};
} catch (err) {
this.logger.error(err);
return new HttpException(err.message, err.status);
}
}
async resendFiles(claimId, file, query, actor) {
let claimFile = await this.claimRequestManagementDbService.findOne(claimId);
if (!claimFile) {
throw new NotFoundException("Claim file not found");
}
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
if (String(claimFile.userId) !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
switch (query.fields) {
case "resendDocuments":
return this.resendDocumentsController(claimFile, query, file);
case "resendCarParts":
return this.resendCarParts(claimFile, query, file);
default:
return new HttpException("wrong query string", HttpStatus.BAD_REQUEST);
}
}
async handleUserObjectionAndParts(
claimRequestId: string,
userObjectionDto: UserObjectionDto,
) {
const claimRequest = await this.claimDbService.findOne(claimRequestId);
if (!claimRequest) {
throw new NotFoundException("Claim request not found");
}
if (
claimRequest.claimStatus !== ReqClaimStatus.WaitingForUserToResend &&
claimRequest.claimStatus !== ReqClaimStatus.CheckedRequest
) {
throw new BadRequestException(
"درخواست شما در این حالت نمی تواند انجام شود",
);
}
if (claimRequest.objection)
throw new BadRequestException("Objection already exists!");
const updatePayload: any = {
objection: userObjectionDto,
};
if (userObjectionDto.newParts?.length) {
const newPartsWithIds = userObjectionDto.newParts.map((part) => ({
...part,
partId: part.partId || new Types.ObjectId(),
}));
updatePayload["newParts"] = newPartsWithIds;
}
const updated = await this.claimDbService.findAndUpdate(
claimRequestId,
{
...updatePayload,
claimStatus: ReqClaimStatus.CheckAgain,
},
{ new: true },
);
return updated.objection;
}
async inPersonVisit(requestId: string, branchId: string, actorDetail: any) {
const request =
await this.claimRequestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Claim not found");
}
const branch = await this.branchDbService.findById(branchId);
if (!branch) {
throw new NotFoundException(`Branch with ID ${branchId} not found.`);
}
if (String(request.userClientKey) !== String(branch.clientKey)) {
throw new ForbiddenException(
"This branch does not belong to the insurer of this claim.",
);
}
const updated = await this.claimRequestManagementDbService.findAndUpdate(
requestId,
{
claimStatus: ReqClaimStatus.InPersonVisit,
visitLocation: `Branch ${branch.name} at ${branch.address}`,
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
return updated;
}
async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId);
}
private handleError(error) {
this.logger.error(error);
throw error;
}
/**
* Convert Gregorian date to Persian date format (1404/08/13)
*/
private convertToPersianDate(date: Date | string): string {
const d = new Date(date);
const persianDate = new Date(d).toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Convert Persian digits to English and format as YYYY/MM/DD
const persianToEnglish: { [key: string]: string } = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
};
return persianDate
.split("/")
.map((part) =>
part
.split("")
.map((char) => persianToEnglish[char] || char)
.join(""),
)
.join("/");
}
/**
* Get time in 24-hour format (HH:mm) from date
*/
private getTime24Hour(date: Date | string): string {
const d = new Date(date);
const hours = d.getHours().toString().padStart(2, "0");
const minutes = d.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
/**
* Call reverse geocoding API to get address from lat/lon
*/
private async getAddressFromCoordinates(
lat: number,
lon: number,
): Promise<string> {
try {
const apiKey =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg";
const response = await axios.get(
`https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`,
{
headers: {
accept: "application/json",
"x-api-key": apiKey,
},
},
);
return response.data?.address_compact || "";
} catch (error) {
this.logger.error("Failed to get address from coordinates", error);
return "";
}
}
/**
* Calculate EstimateAmount from damageExpertReply parts
*/
private calculateEstimateAmount(parts: any[]): number {
if (!parts || !Array.isArray(parts) || parts.length === 0) {
return 0;
}
return parts.reduce((sum, part) => {
const totalPayment = part.totalPayment
? parseFloat(part.totalPayment.toString())
: 0;
return sum + (isNaN(totalPayment) ? 0 : totalPayment);
}, 0);
}
/**
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
*/
async fanavaranSubmit(claimRequestId: string): Promise<any> {
try {
// Query claim-request-management with only needed fields using aggregate for field selection
const claimRequestResult = await this.claimRequestManagementDbService.aggregate([
{ $match: { _id: new Types.ObjectId(claimRequestId) } },
{
$project: {
blameFile: 1,
damageExpertReply: 1,
damageExpertReplyFinal: 1,
},
},
]);
if (!claimRequestResult || claimRequestResult.length === 0) {
throw new NotFoundException("Claim request not found");
}
const claimRequest = claimRequestResult[0];
if (!claimRequest) {
throw new NotFoundException("Claim request not found");
}
if (!claimRequest.blameFile || !claimRequest.blameFile._id) {
throw new BadRequestException("Blame file not found in claim request");
}
const blameRequestId = claimRequest.blameFile._id.toString();
// Query request-management with only needed fields using aggregate for field selection
const blameRequestResult = await this.blameDbService.aggregate([
{ $match: { _id: new Types.ObjectId(blameRequestId) } },
{
$project: {
expertSubmitReplyFinal: 1,
expertSubmitReply: 1,
createdAt: 1,
firstPartyLocation: 1,
"firstPartyDetails.firstPartyId": 1,
"firstPartyDetails.nationalCodeOfInsurer": 1,
"secondPartyDetails.secondPartyId": 1,
"secondPartyDetails.nationalCodeOfInsurer": 1,
},
},
]);
if (!blameRequestResult || blameRequestResult.length === 0) {
throw new NotFoundException("Blame request not found");
}
const blameRequest = blameRequestResult[0];
if (!blameRequest) {
throw new NotFoundException("Blame request not found");
}
// Start building the response object
const result: any = {
AccidentCityId: 701,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 1589,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
AccidentCauseId: 6, // Initialize to null, will be set if found
};
// AccidentCauseId: from expertSubmitReplyFinal.fields.accidentReason.fanavaran
// If fanavaran is 0, use id instead. First check if expertSubmitReplyFinal exists, if not check expertSubmitReply
if (blameRequest.expertSubmitReplyFinal) {
// expertSubmitReplyFinal exists, use it for AccidentCauseId
const accidentReason =
blameRequest.expertSubmitReplyFinal?.fields?.accidentReason;
const fanavaranValue = accidentReason?.fanavaran;
const idValue = accidentReason?.id;
if (fanavaranValue !== undefined && fanavaranValue !== null) {
// If fanavaran is 0, use id instead
if (fanavaranValue === 0) {
result.AccidentCauseId =
idValue !== undefined && idValue !== null ? idValue : null;
} else {
result.AccidentCauseId = fanavaranValue;
}
} else {
this.logger.warn(
`[Fanavaran Submit] expertSubmitReplyFinal exists but fanavaran is null or undefined`,
);
result.AccidentCauseId = null;
}
} else if (blameRequest.expertSubmitReply) {
// expertSubmitReplyFinal is null, use expertSubmitReply
const accidentReason =
blameRequest.expertSubmitReply?.fields?.accidentReason;
const fanavaranValue = accidentReason?.fanavaran;
const idValue = accidentReason?.id;
if (fanavaranValue !== undefined && fanavaranValue !== null) {
// If fanavaran is 0, use id instead
if (fanavaranValue === 0) {
result.AccidentCauseId =
idValue !== undefined && idValue !== null ? idValue : null;
} else {
result.AccidentCauseId = fanavaranValue;
}
} else {
this.logger.warn(
`[Fanavaran Submit] expertSubmitReply exists but fanavaran is null or undefined`,
);
result.AccidentCauseId = null;
}
} else {
this.logger.error(
`[Fanavaran Submit] Both expertSubmitReplyFinal and expertSubmitReply are null/undefined`,
);
result.AccidentCauseId = null;
}
// AccidentDate: Convert createdAt to Persian date
if (blameRequest.createdAt) {
result.AccidentDate = this.convertToPersianDate(blameRequest.createdAt);
result.AnnouncementDate = result.AccidentDate;
result.DocReceivedDate = result.AccidentDate;
}
// AccidentTime: Extract time from createdAt in 24-hour format
if (blameRequest.createdAt) {
result.AccidentTime = this.getTime24Hour(blameRequest.createdAt);
}
// AccidentLocationAddress: Get from reverse geocoding API
if (
blameRequest.firstPartyLocation?.lat &&
blameRequest.firstPartyLocation?.lon
) {
result.AccidentLocationAddress = await this.getAddressFromCoordinates(
blameRequest.firstPartyLocation.lat,
blameRequest.firstPartyLocation.lon,
);
}
// EstimateAmount: Sum of totalPayment from damageExpertReply parts
const damageReply =
claimRequest.damageExpertReplyFinal || claimRequest.damageExpertReply;
if (damageReply?.parts) {
result.EstimateAmount = this.calculateEstimateAmount(
damageReply.parts,
);
} else {
result.EstimateAmount = 0;
}
// Keep other fields from template with default values
result.AccidentCulpritId = null; //todo
result.CouponNo = null;
result.CourtArchiveNo = null;
result.CulpritLicenceCityId = null;
result.CulpritLicenceCountryId = null;
result.CulpritLicenceForeignCityName = null;
result.CulpritLicenceIssuDate = "1394/10/13"; //todo
result.CulpritLicenceNo = "1124242";
result.DamagedCount = 1;
result.DmgAssessorFirstCreationTime = null;
result.EntryDate = null;
result.IsLicenseMatchWithVehicleKind = 1;
result.HasOtherCulprit = 0;
result.IsAccidentOutOfBorder = 0;
result.IsFatalAccident = 0;
result.IsPlaqueChanged = 0;
result.PlaqueCityId = null;
result.PlaqueKindId = null;
result.PlaqueLeftNo = null;
result.PlaqueMiddleCodeId = null;
result.PlaqueNo = null;
result.PlaqueRightNo = null;
result.PlaqueSampleId = null;
result.PlaqueSerial = null;
result.PoliceOfficerId = 1;
result.PoliceReportDesc = null;
result.PoliceReportSeri = null;
result.PoliceReportSerial = null;
result.PolicyId = null;
result.PreviousPolicyEndDate = "";
result.TrackingCode = null;
result.IsLicenseReplacement = 0;
result.UnknownCulpritCauseId = null;
// Get PolicyId from external API
try {
const policyId = await this.getPolicyIdFromExternalApi(
blameRequest,
claimRequestId,
);
if (policyId) {
result.PolicyId = policyId;
}
} catch (error) {
this.logger.error(
`[Fanavaran Submit] Failed to get PolicyId from external API:`,
error,
);
// Continue without PolicyId if API call fails
}
return result;
} catch (error) {
this.logger.error("Error in fanavaranSubmit", error);
throw error;
}
}
/**
* Step 1: Get appToken from GetAppToken API
*/
private async getAppToken(): Promise<string> {
try {
const requestHeaders: any = {
appname: this.APP_NAME,
secret: this.SECRET,
"Content-Length": "0",
};
delete requestHeaders["Content-Type"];
const response = await axios({
method: "POST",
url: this.GET_APP_TOKEN_URL,
data: "",
headers: requestHeaders,
transformRequest: [
(data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
const appToken =
response.headers.apptoken ||
response.headers.appToken ||
response.headers["apptoken"] ||
response.headers["appToken"];
if (!appToken) {
this.logger.error("Failed to get appToken from response headers");
throw new BadGatewayException("Failed to get appToken from response headers");
}
this.logger.log(`Successfully obtained appToken`);
return appToken;
} catch (error) {
this.logger.error("Failed to get appToken", error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to get appToken from external API";
throw new BadGatewayException(errorMessage);
}
throw new BadGatewayException("Failed to get appToken from external API");
}
}
/**
* Step 2: Login to get authenticationToken
*/
private async login(appToken: string): Promise<string> {
try {
const requestHeaders: any = {
appToken: appToken,
userName: this.USERNAME,
password: this.PASSWORD,
"Content-Length": "0",
};
delete requestHeaders["Content-Type"];
const response = await axios({
method: "POST",
url: this.LOGIN_URL,
data: "",
headers: requestHeaders,
transformRequest: [
(data, headers) => {
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
// Check response headers first (authenticationToken is in headers)
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
if (!authenticationToken) {
this.logger.error("Failed to get authenticationToken from response");
throw new BadGatewayException("Failed to get authenticationToken from response");
}
this.logger.log(`Successfully obtained authenticationToken`);
return authenticationToken;
} catch (error) {
this.logger.error("Failed to login", error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to login to external API";
throw new BadGatewayException(errorMessage);
}
throw new BadGatewayException("Failed to login to external API");
}
}
/**
* Get PolicyId from external API
*/
private async getPolicyIdFromExternalApi(
blameRequest: any,
claimRequestId: string,
): Promise<number | null> {
try {
// Get guiltyUserId from expertSubmitReplyFinal or expertSubmitReply
let guiltyUserId: string | null = null;
if (blameRequest.expertSubmitReplyFinal?.guiltyUserId) {
guiltyUserId = blameRequest.expertSubmitReplyFinal.guiltyUserId;
} else if (blameRequest.expertSubmitReply?.guiltyUserId) {
guiltyUserId = blameRequest.expertSubmitReply.guiltyUserId;
}
if (!guiltyUserId) {
this.logger.warn(
`[Fanavaran Submit] guiltyUserId not found in expertSubmitReplyFinal or expertSubmitReply`,
);
return null;
}
// Find the nationalCodeOfInsurer by matching guiltyUserId with firstPartyId or secondPartyId
let nationalCodeOfInsurer: string | null = null;
if (
blameRequest.firstPartyDetails?.firstPartyId?.toString() ===
guiltyUserId.toString()
) {
nationalCodeOfInsurer =
blameRequest.firstPartyDetails?.nationalCodeOfInsurer;
} else if (
blameRequest.secondPartyDetails?.secondPartyId?.toString() ===
guiltyUserId.toString()
) {
nationalCodeOfInsurer =
blameRequest.secondPartyDetails?.nationalCodeOfInsurer;
}
if (!nationalCodeOfInsurer) {
this.logger.warn(
`[Fanavaran Submit] nationalCodeOfInsurer not found for guiltyUserId: ${guiltyUserId}`,
);
return null;
}
// Get authenticationToken
const appToken = await this.getAppToken();
const authenticationToken = await this.login(appToken);
// Call external API to get PolicyId
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
this.logger.log(
`[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
);
const response = await axios.get(policyInquiryUrl, {
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
});
if (
Array.isArray(response.data) &&
response.data.length > 0 &&
response.data[0].PolicyId
) {
const policyId = response.data[0].PolicyId;
this.logger.log(
`[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`,
);
return policyId;
} else {
this.logger.warn(
`[Fanavaran Submit] No PolicyId found in API response`,
);
return null;
}
} catch (error) {
this.logger.error(
`[Fanavaran Submit] Error getting PolicyId from external API:`,
error,
);
if (axios.isAxiosError(error)) {
const errorMessage =
error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to get PolicyId from external API";
this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`);
}
throw error;
}
}
/**
* Submit fanavaran data to external API
*/
async submitToFanavaran(claimRequestId: string): Promise<any> {
try {
// First, get the mapped data
this.logger.log(`[Fanavaran Submit] Starting submission for claimRequestId: ${claimRequestId}`);
const fanavaranData = await this.fanavaranSubmit(claimRequestId);
this.logger.log(`[Fanavaran Submit] Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2));
// Validate required fields before submission
if (fanavaranData.AccidentCauseId === undefined || fanavaranData.AccidentCauseId === null) {
this.logger.error(`[Fanavaran Submit] CRITICAL: AccidentCauseId is missing or null!`);
this.logger.error(`[Fanavaran Submit] This field is required by the API. Request will likely fail.`);
this.logger.error(`[Fanavaran Submit] Available fields in result:`, Object.keys(fanavaranData));
} else {
this.logger.log(`[Fanavaran Submit] AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`);
}
// Step 1: Get appToken
this.logger.log(`[Fanavaran Submit] Step 1: Getting appToken`);
const appToken = await this.getAppToken();
this.logger.log(`[Fanavaran Submit] AppToken obtained: ${appToken}`);
// Step 2: Login to get authenticationToken
this.logger.log(`[Fanavaran Submit] Step 2: Logging in to get authenticationToken`);
const authenticationToken = await this.login(appToken);
this.logger.log(`[Fanavaran Submit] AuthenticationToken obtained: ${authenticationToken}`);
// Step 3: Submit data to Fanavaran API
this.logger.log(`[Fanavaran Submit] Step 3: Submitting data to Fanavaran API`);
this.logger.log(`[Fanavaran Submit] URL: ${this.FANAVARAN_SUBMIT_URL}`);
this.logger.log(`[Fanavaran Submit] Request headers:`, {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
});
this.logger.log(`[Fanavaran Submit] Request body:`, JSON.stringify(fanavaranData, null, 2));
const response = await axios.post(
this.FANAVARAN_SUBMIT_URL,
fanavaranData,
{
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
},
);
this.logger.log(`[Fanavaran Submit] API Response Status: ${response.status}`);
this.logger.log(`[Fanavaran Submit] API Response Headers:`, JSON.stringify(response.headers, null, 2));
this.logger.log(`[Fanavaran Submit] API Response Data:`, JSON.stringify(response.data, null, 2));
this.logger.log("Successfully submitted data to Fanavaran API");
// Step 4: Save ClaimNo and Id from response to claim-request-management document
if (response.data) {
const claimNo = response.data.ClaimNo;
const claimId = response.data.Id;
this.logger.log(`[Fanavaran Submit] Extracted from response - ClaimNo: ${claimNo}, Id: ${claimId}`);
if (claimNo !== undefined || claimId !== undefined) {
const updateData: any = {
$set: {},
};
if (claimNo !== undefined) {
updateData.$set.claimNo = claimNo;
}
if (claimId !== undefined) {
updateData.$set.claimId = claimId;
}
this.logger.log(`[Fanavaran Submit] Updating document with:`, JSON.stringify(updateData, null, 2));
await this.claimRequestManagementDbService.findAndUpdate(
claimRequestId,
updateData,
);
this.logger.log(
`Updated claim-request-management document with claimNo: ${claimNo}, claimId: ${claimId}`,
);
} else {
this.logger.warn(`[Fanavaran Submit] No ClaimNo or Id found in response data`);
}
} else {
this.logger.warn(`[Fanavaran Submit] No response data received`);
}
return response.data;
} catch (error) {
this.logger.error("[Fanavaran Submit] Error submitting to Fanavaran");
this.logger.error("[Fanavaran Submit] Error type:", error?.constructor?.name);
if (axios.isAxiosError(error)) {
this.logger.error("[Fanavaran Submit] Axios Error Details:");
this.logger.error("[Fanavaran Submit] Error Status:", error.response?.status);
this.logger.error("[Fanavaran Submit] Error Status Text:", error.response?.statusText);
this.logger.error("[Fanavaran Submit] Error Response Headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("[Fanavaran Submit] Error Response Data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("[Fanavaran Submit] Request URL:", error.config?.url);
this.logger.error("[Fanavaran Submit] Request Method:", error.config?.method);
this.logger.error("[Fanavaran Submit] Request Headers:", JSON.stringify(error.config?.headers, null, 2));
this.logger.error("[Fanavaran Submit] Request Data:", JSON.stringify(error.config?.data, null, 2));
const errorMessage = error.response?.data?.message ||
error.response?.data?.Message ||
error.response?.data ||
error.message ||
"Failed to submit data to Fanavaran API";
this.logger.error("[Fanavaran Submit] Extracted Error Message:", errorMessage);
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("[Fanavaran Submit] Non-Axios Error:", error);
throw new BadGatewayException("Failed to submit data to Fanavaran API");
}
}
}
/**
* V2: Create claim request from completed blame case
* Only damaged party can create claim after both parties accept expert decision
*/
async createClaimFromBlameV2(
blameRequestId: string,
currentUserId: string,
): Promise<CreateClaimFromBlameResponseDto> {
try {
// 1. Fetch blame request from new blameCases collection
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
if (!blameRequest) {
throw new NotFoundException("Blame request not found");
}
// 2. Validate blame status is COMPLETED
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
);
}
const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
if (isCarBody) {
// CAR_BODY: single party, no expert; first party is the damaged one who creates the claim
if (parties.length < 1) {
throw new BadRequestException("Blame request has no party");
}
const firstParty = parties.find((p) => p.role === "FIRST");
if (!firstParty || String(firstParty.person?.userId) !== currentUserId) {
throw new ForbiddenException(
"Only the first party (damaged) can create a claim from this CAR_BODY blame request",
);
}
} else {
// THIRD_PARTY: require expert decision and both parties signed
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
if (parties.length < 2) {
throw new BadRequestException(
"Both parties must be present in the blame request",
);
}
const allPartiesSigned = parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
const guiltyParty = parties.find((p) => {
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
}
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
const currentUserParty = parties.find(
(p) => String(p.person?.userId) === currentUserId,
);
if (!currentUserParty) {
throw new ForbiddenException(
"You are not a party in this blame request",
);
}
// 6. Validate no existing claim for this blame
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
});
if (existingClaim) {
throw new ConflictException(
"A claim request for this blame case already exists",
);
}
// 7. Generate claim number
const claimNo = await this.generateUniqueClaimNumber();
// 8. Create claim case
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING,
workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
owner: {
userId: new Types.ObjectId(currentUserId),
userRole: currentUserParty.role as any,
},
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: currentUserParty.person?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
blameRequestId,
blamePublicId: blameRequest.publicId,
},
},
],
} as any);
this.logger.log(
`Claim created: ${newClaim._id} from blame: ${blameRequestId}`,
);
return {
claimRequestId: String(newClaim._id),
publicId: blameRequest.publicId,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
message: "Claim request created successfully",
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"createClaimFromBlameV2 failed",
blameRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to create claim request",
);
}
}
/**
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
*
* @param claimRequestId - The claim case ID
* @param body - Selected outer parts
* @param currentUserId - The current user's ID
* @returns Response with updated claim information
*
* Validations:
* - Claim case must exist
* - User must be the claim owner (damaged party)
* - Current workflow step must be CLAIM_CREATED (step 1)
* - Selected parts array must not be empty
* - Parts must not have been selected previously
*/
async selectOuterPartsV2(
claimRequestId: string,
body: SelectOuterPartsV2Dto,
currentUserId: string,
): Promise<SelectOuterPartsV2ResponseDto> {
try {
// 1. Validate claim exists
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(
`Claim case with ID ${claimRequestId} not found`
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can select damaged parts'
);
}
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OUTER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// 4. Validate outer parts haven't been selected yet
if (claimCase.damage?.selectedParts && claimCase.damage.selectedParts.length > 0) {
throw new ConflictException(
'Outer parts have already been selected for this claim'
);
}
// 5. Convert selected parts to storage format (simple array of strings)
const selectedParts = body.selectedParts;
// 6. Update claim case with selected parts and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
'damage.selectedParts': selectedParts,
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
'workflow.nextStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OUTER_PARTS,
history: {
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts: selectedParts,
partsCount: selectedParts.length,
description: `User selected ${selectedParts.length} damaged outer parts`,
},
},
},
},
);
if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case');
}
this.logger.log(
`Outer parts selected for claim ${claimRequestId}: ${selectedParts.length} parts`,
);
// 7. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
selectedParts: selectedParts,
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
'selectOuterPartsV2 failed',
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: 'Failed to select outer parts',
);
}
}
/**
* V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow)
*
* @param claimRequestId - The claim case ID
* @param body - Other parts, Sheba number, and national code
* @param currentUserId - The current user's ID
* @returns Response with updated claim information
*
* Validations:
* - Claim case must exist
* - User must be the claim owner (damaged party)
* - Current workflow step must be SELECT_OTHER_PARTS (step 3)
* - Other parts and bank info must not have been submitted previously
*/
async selectOtherPartsV2(
claimRequestId: string,
body: SelectOtherPartsV2Dto,
currentUserId: string,
): Promise<SelectOtherPartsV2ResponseDto> {
try {
// 1. Validate claim exists
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(
`Claim case with ID ${claimRequestId} not found`
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can submit other parts and bank information'
);
}
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// 4. Validate other parts and bank info haven't been submitted yet
if (claimCase.money?.sheba || claimCase.money?.nationalCodeOfInsurer) {
throw new ConflictException(
'Bank information has already been submitted for this claim'
);
}
// 5. Prepare data
const otherParts = body.otherParts || [];
const shebaNumber = body.shebaNumber;
const nationalCode = body.nationalCodeOfOwner;
// 6. Update claim case with other parts, bank info and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
'money.sheba': shebaNumber,
'money.nationalCodeOfInsurer': nationalCode,
'status': ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS,
history: {
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
otherParts: otherParts,
otherPartsCount: otherParts.length,
hasBankInfo: true,
description: `User selected ${otherParts.length} other damaged parts and provided bank information`,
},
},
},
},
);
if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case');
}
this.logger.log(
`Other parts and bank info submitted for claim ${claimRequestId}: ${otherParts.length} parts`,
);
// 7. Mask sensitive data for response
const maskedSheba = shebaNumber.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3');
const maskedNationalCode = nationalCode.replace(/^(.{2})(.*)(.{2})$/, '$1******$3');
// 8. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
otherParts: otherParts,
shebaNumber: maskedSheba,
nationalCodeOfOwner: maskedNationalCode,
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
'selectOtherPartsV2 failed',
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: 'Failed to select other parts',
);
}
}
/**
* V2 API: Get capture requirements for claim (documents, angles, parts)
*/
async getCaptureRequirementsV2(
claimRequestId: string,
currentUserId: string,
): Promise<GetCaptureRequirementsV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can view capture requirements');
}
// Helper to get part labels
const getPartLabel = (key: string): { fa: string; en: string } => {
const labels = {
hood: { fa: 'کاپوت', en: 'Hood' },
trunk: { fa: 'صندوق عقب', en: 'Trunk' },
roof: { fa: 'سقف', en: 'Roof' },
front_right_door: { fa: 'درب جلو راست', en: 'Front Right Door' },
front_left_door: { fa: 'درب جلو چپ', en: 'Front Left Door' },
rear_right_door: { fa: 'درب عقب راست', en: 'Rear Right Door' },
rear_left_door: { fa: 'درب عقب چپ', en: 'Rear Left Door' },
front_bumper: { fa: 'سپر جلو', en: 'Front Bumper' },
rear_bumper: { fa: 'سپر عقب', en: 'Rear Bumper' },
front_right_fender: { fa: 'گلگیر جلو راست', en: 'Front Right Fender' },
front_left_fender: { fa: 'گلگیر جلو چپ', en: 'Front Left Fender' },
rear_right_fender: { fa: 'گلگیر عقب راست', en: 'Rear Right Fender' },
rear_left_fender: { fa: 'گلگیر عقب چپ', en: 'Rear Left Fender' },
};
return labels[key] || { fa: key, en: key };
};
// Required documents
const requiredDocsDefinition = [
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' },
{ key: 'damaged_driving_license_front', label_fa: 'گواهینامه طرف آسیب‌دیده - جلو', label_en: 'Damaged Party License - Front', category: 'damaged_party' },
{ key: 'damaged_driving_license_back', label_fa: 'گواهینامه طرف آسیب‌دیده - پشت', label_en: 'Damaged Party License - Back', category: 'damaged_party' },
{ key: 'damaged_chassis_number', label_fa: 'شماره شاسی', label_en: 'Chassis Number', category: 'damaged_party' },
{ key: 'damaged_engine_photo', label_fa: 'عکس موتور', label_en: 'Engine Photo', category: 'damaged_party' },
{ key: 'damaged_car_card_front', label_fa: 'کارت خودرو آسیب‌دیده - جلو', label_en: 'Damaged Car Card - Front', category: 'damaged_party' },
{ key: 'damaged_car_card_back', label_fa: 'کارت خودرو آسیب‌دیده - پشت', label_en: 'Damaged Car Card - Back', category: 'damaged_party' },
{ key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیب‌دیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
];
const requiredDocuments = requiredDocsDefinition.map(doc => {
const docData = (claimCase.requiredDocuments as any)?.get?.(doc.key);
return {
key: doc.key,
label_fa: doc.label_fa,
label_en: doc.label_en,
category: doc.category,
uploaded: docData?.uploaded || false,
};
});
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Car angles
const carAngles = [
{ key: 'front', label_fa: 'جلو', label_en: 'Front' },
{ key: 'back', label_fa: 'عقب', label_en: 'Back' },
{ key: 'left', label_fa: 'چپ', label_en: 'Left' },
{ key: 'right', label_fa: 'راست', label_en: 'Right' },
].map(angle => ({
key: angle.key,
label_fa: angle.label_fa,
label_en: angle.label_en,
captured: !!hasCapture(claimCase.media?.carAngles as any, angle.key),
}));
// Damaged parts from selected parts
const selectedParts = claimCase.damage?.selectedParts || [];
const damagedParts = selectedParts.map(partKey => {
const label = getPartLabel(partKey);
return {
key: partKey,
label_fa: label.fa,
label_en: label.en,
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
};
});
// Calculate progress
const documentsUploaded = requiredDocuments.filter(d => d.uploaded).length;
const anglesCaptured = carAngles.filter(a => a.captured).length;
const partsCaptured = damagedParts.filter(p => p.captured).length;
const totalRemaining =
(requiredDocuments.length - documentsUploaded) +
(carAngles.length - anglesCaptured) +
(damagedParts.length - partsCaptured);
return {
claimRequestId: claimCase._id.toString(),
publicId: claimCase.publicId,
currentStep: claimCase.workflow?.currentStep || '',
requiredDocuments,
carAngles,
damagedParts,
totalRemaining,
progress: {
documentsUploaded,
documentsTotal: requiredDocuments.length,
anglesCaptured,
anglesTotal: carAngles.length,
partsCaptured,
partsTotal: damagedParts.length,
},
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getCaptureRequirementsV2 failed', error);
throw new InternalServerErrorException('Failed to get capture requirements');
}
}
/**
* V2 API: Upload required document
*/
async uploadRequiredDocumentV2(
claimRequestId: string,
body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File,
currentUserId: string,
): Promise<UploadRequiredDocumentV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can upload documents');
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// Check if document already uploaded
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
if (existingDoc?.uploaded) {
throw new ConflictException(`Document ${body.documentKey} has already been uploaded`);
}
const fileUrl = buildFileLink(file.path);
// Create document reference in claim-required-documents collection
const docRef = await this.claimRequiredDocumentDbService.create({
path: file.path,
fileName: file.filename,
claimId: new Types.ObjectId(claimRequestId),
documentType: body.documentKey,
uploadedAt: new Date(),
});
// Update claim case
const updateData: any = {
[`requiredDocuments.${body.documentKey}`]: {
fileId: docRef._id,
filePath: file.path,
fileName: file.filename,
uploaded: true,
uploadedAt: new Date(),
},
$push: {
history: {
type: 'DOCUMENT_UPLOADED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
documentKey: body.documentKey,
fileName: file.filename,
fileId: docRef._id.toString(),
},
},
},
};
// Check if all documents are uploaded
const totalDocs = 13;
const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
const allDocumentsUploaded = uploadedCount >= totalDocs;
// If all documents uploaded, move to next step
if (allDocumentsUploaded) {
updateData['status'] = ClaimCaseStatus.CAPTURING_PART_DAMAGES;
updateData['workflow.currentStep'] = ClaimWorkflowStep.CAPTURE_PART_DAMAGES;
updateData['workflow.nextStep'] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
updateData.$push.history = [
updateData.$push.history,
{
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
description: 'All required documents uploaded',
},
},
];
updateData.$push['workflow.completedSteps'] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
const remaining = totalDocs - uploadedCount;
const message = allDocumentsUploaded
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
: `Document uploaded successfully. ${remaining} documents remaining.`;
return {
claimRequestId: claimCase._id.toString(),
documentKey: body.documentKey,
fileUrl,
allDocumentsUploaded,
currentStep: allDocumentsUploaded ? ClaimWorkflowStep.CAPTURE_PART_DAMAGES : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('uploadRequiredDocumentV2 failed', error);
throw new InternalServerErrorException('Failed to upload document');
}
}
/**
* V2 API: Capture car angle or damaged part
*/
async capturePartV2(
claimRequestId: string,
body: CapturePartV2Dto,
file: Express.Multer.File,
currentUserId: string,
): Promise<CapturePartV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can capture parts');
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES}, but current step is ${claimCase.workflow?.currentStep}`
);
}
const fileUrl = buildFileLink(file.path);
const captureData = {
path: file.path,
fileName: file.filename,
url: fileUrl,
capturedAt: new Date(),
};
const updateData: any = {
$push: {
history: {
type: body.captureType === 'angle' ? 'ANGLE_CAPTURED' : 'PART_CAPTURED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
captureType: body.captureType,
captureKey: body.captureKey,
fileName: file.filename,
},
},
},
};
if (body.captureType === 'angle') {
updateData[`media.carAngles.${body.captureKey}`] = captureData;
} else {
updateData[`media.damagedParts.${body.captureKey}`] = captureData;
}
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
updateData,
);
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Use same logic as getCaptureRequirementsV2 to determine completion
const anglesKeys = ['front', 'back', 'left', 'right'];
const carAnglesData = updatedClaim?.media?.carAngles as any;
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
const selectedParts = updatedClaim?.damage?.selectedParts || [];
const anglesCaptured = anglesKeys.filter(k => hasCapture(carAnglesData, k)).length;
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
'workflow.currentStep': ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
'workflow.nextStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
history: {
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description: 'User submission complete. Claim ready for damage expert review.',
},
},
},
});
}
const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured);
const message = allCapturesComplete
? 'All captures complete. Your claim is now ready for damage expert review.'
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
return {
claimRequestId: claimCase._id.toString(),
captureType: body.captureType,
captureKey: body.captureKey,
fileUrl,
allCapturesComplete,
currentStep: allCapturesComplete
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('capturePartV2 failed', error);
throw new InternalServerErrorException('Failed to capture part');
}
}
/**
* V2 API: Get list of claims for current user
*/
async getMyClaimsV2(currentUserId: string): Promise<GetMyClaimsV2ResponseDto> {
try {
const claims = await this.claimCaseDbService.find(
{ 'owner.userId': new Types.ObjectId(currentUserId) },
{ lean: true },
);
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
requestNo: c.requestNo,
status: c.status,
claimStatus: c.claimStatus || 'PENDING',
currentStep: c.workflow?.currentStep || '',
createdAt: c.createdAt,
blameRequestId: c.blameRequestId?.toString(),
})) as ClaimListItemV2Dto[];
return { list, total: list.length };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getMyClaimsV2 failed', error);
throw new InternalServerErrorException('Failed to get claims list');
}
}
/**
* V2 API: Get claim details by ID (owner only)
*/
async getClaimDetailsV2(
claimRequestId: string,
currentUserId: string,
): Promise<ClaimDetailsV2ResponseDto> {
try {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (!claim.owner?.userId || claim.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('You do not have access to this claim');
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, { uploaded: boolean; fileId?: string }> = {};
if (requiredDocs) {
const keys = requiredDocs instanceof Map ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs);
for (const k of keys) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
};
}
}
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),
};
}
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),
};
}
const maskSheba = (s?: string) =>
s ? s.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3') : undefined;
const maskNationalCode = (s?: string) =>
s ? s.replace(/^(.{2})(.*)(.{2})$/, '$1******$3') : undefined;
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
requestNo: claim.requestNo,
status: claim.status,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: claim.vehicle,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
money: claim.money
? {
sheba: maskSheba(claim.money.sheba),
nationalCodeOfOwner: maskNationalCode(claim.money.nationalCodeOfInsurer),
}
: undefined,
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
carAngles,
damagedParts,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getClaimDetailsV2 failed', error);
throw new InternalServerErrorException('Failed to get claim details');
}
}
private async generateUniqueClaimNumber(): Promise<string> {
const prefix = "CL";
const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits
return `${prefix}${randomPart}`;
}
}