forked from Yara724/api
blame and claim refactored
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -32,6 +34,21 @@ 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 { 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";
|
||||
@@ -41,12 +58,11 @@ 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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
import { RoleEnum } from "src/Types&Enums/role.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()
|
||||
@@ -75,6 +91,8 @@ export class ClaimRequestManagementService {
|
||||
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,
|
||||
@@ -86,6 +104,7 @@ export class ClaimRequestManagementService {
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly branchDbService: BranchDbService,
|
||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||
private readonly publicIdService: PublicIdService,
|
||||
) {}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
@@ -152,8 +171,22 @@ export class ClaimRequestManagementService {
|
||||
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(
|
||||
@@ -2603,4 +2636,911 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate expert decision exists
|
||||
if (!blameRequest.expert?.decision) {
|
||||
throw new BadRequestException(
|
||||
"Cannot create claim until expert has made a decision",
|
||||
);
|
||||
}
|
||||
|
||||
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
|
||||
|
||||
// 4. Validate both parties have signed and accepted
|
||||
const parties = blameRequest.parties || [];
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Validate current user is the DAMAGED party (NOT the guilty party)
|
||||
const currentUserParty = parties.find(
|
||||
(p) => String(p.person?.userId) === currentUserId,
|
||||
);
|
||||
|
||||
if (!currentUserParty) {
|
||||
throw new ForbiddenException(
|
||||
"You are not a party in this blame request",
|
||||
);
|
||||
}
|
||||
|
||||
const currentUserIsGuilty = parties.some(
|
||||
(p) =>
|
||||
String(p.person?.userId) === currentUserId &&
|
||||
String((p as any)._id || p.person?.userId) === guiltyPartyId,
|
||||
);
|
||||
|
||||
// Better check: compare guiltyPartyId with person.userId of each party
|
||||
const guiltyParty = parties.find((p) => {
|
||||
// guiltyPartyId could be userId or party identifier
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user