1
0
forked from Yara724/api

blame and claim refactored

This commit is contained in:
2026-02-24 12:19:25 +03:30
parent 0d8858f458
commit 35732dd70a
81 changed files with 11603 additions and 77 deletions

View File

@@ -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}`;
}
}