diff --git a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts index e79b502..10711c6 100644 --- a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts @@ -10,6 +10,9 @@ export enum ClaimCaseStatus { // User flow - damage capture CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES", + + /** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */ + WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND", // Expert flow WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT", diff --git a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts index 64fd804..bab1390 100644 --- a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts @@ -15,6 +15,9 @@ export enum ClaimWorkflowStep { // User submission complete USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE", + + /** Owner fulfilling damage expert resend (extra documents and/or part images). */ + USER_EXPERT_RESEND = "USER_EXPERT_RESEND", // Expert: Damage assessment EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT", diff --git a/src/Types&Enums/claim-request-management/required-document-type.enum.ts b/src/Types&Enums/claim-request-management/required-document-type.enum.ts index d4cceba..2596530 100644 --- a/src/Types&Enums/claim-request-management/required-document-type.enum.ts +++ b/src/Types&Enums/claim-request-management/required-document-type.enum.ts @@ -1,6 +1,9 @@ export enum ClaimRequiredDocumentType { // Car green card CAR_GREEN_CARD = "car_green_card", + + /** National ID card (or similar); may be requested on resend even if not in the initial upload set. */ + NATIONAL_CARD = "national_card", // Damaged party documents DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back", diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 2ed9667..94962d1 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -72,6 +72,13 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { UserRatingDto } from "./dto/user-rating.dto"; +import { + canFinalizeExpertResend, + documentKeyAllowedForExpertResend, + normalizeResendDocumentKeys, + normalizeResendPartKeys, + partKeyAllowedForExpertResend, +} from "src/helpers/claim-expert-resend"; @Injectable() export class ClaimRequestManagementService { @@ -4132,6 +4139,105 @@ export class ClaimRequestManagementService { } } + /** + * When the owner has uploaded every expert-requested document/part (or acknowledged description-only resend), + * move the claim back to the damage expert queue (same posture as after initial submission). + */ + private async tryFinalizeExpertResendAfterUserAction( + claimRequestId: string, + actorUserId: string, + ownerDisplayName: string, + ): Promise { + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim || !canFinalizeExpertResend(claim)) { + return; + } + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, + claimStatus: ClaimStatus.PENDING, + "workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + "evaluation.damageExpertResend.fulfilledAt": new Date(), + }, + $push: { + history: { + type: "EXPERT_RESEND_FULFILLED", + actor: { + actorId: new Types.ObjectId(actorUserId), + actorName: ownerDisplayName, + actorType: "user", + }, + timestamp: new Date(), + metadata: {}, + }, + }, + }); + } + + /** + * V2: Owner acknowledges a description-only expert resend (no extra documents or part photos requested). + */ + async acknowledgeExpertResendInstructionsV2( + claimRequestId: string, + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise<{ + claimRequestId: string; + status: string; + claimStatus: string; + currentStep: string; + message: string; + }> { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (!claimCase) { + throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); + } + const effectiveUserId = await this.resolveClaimEffectiveUserId( + claimCase, + currentUserId, + actor?.role, + ); + if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) { + throw new ForbiddenException("Only the claim owner can complete this step"); + } + if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) { + throw new BadRequestException( + `No expert resend step is active. Current step: ${claimCase.workflow?.currentStep ?? "none"}`, + ); + } + if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { + throw new BadRequestException( + `Claim is not waiting for resend action. Current status: ${claimCase.status}`, + ); + } + const r = claimCase.evaluation?.damageExpertResend; + if (!r || r.fulfilledAt) { + throw new BadRequestException("There is no active expert resend request to acknowledge."); + } + if ( + normalizeResendDocumentKeys(r.resendDocuments).length > 0 || + normalizeResendPartKeys(r.resendCarParts).length > 0 + ) { + throw new BadRequestException( + "Upload all requested documents and part photos before completing the resend step.", + ); + } + await this.tryFinalizeExpertResendAfterUserAction( + claimRequestId, + effectiveUserId, + claimCase.owner?.fullName || "User", + ); + const updated = await this.claimCaseDbService.findById(claimRequestId); + return { + claimRequestId, + status: String(updated?.status ?? ""), + claimStatus: String(updated?.claimStatus ?? ""), + currentStep: String(updated?.workflow?.currentStep ?? ""), + message: "Claim returned to the damage expert queue.", + }; + } + /** * V2 API: Upload required document */ @@ -4158,11 +4264,25 @@ export class ClaimRequestManagementService { throw new ForbiddenException('Only the claim owner can upload documents'); } - if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { + const step = claimCase.workflow?.currentStep; + const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; + if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { throw new BadRequestException( - `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}` + `Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, ); } + if (isResendUpload) { + if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { + throw new BadRequestException( + `Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`, + ); + } + if (!documentKeyAllowedForExpertResend(claimCase, body.documentKey)) { + throw new BadRequestException( + `Document "${body.documentKey}" was not requested by the damage expert for this resend.`, + ); + } + } // CAR_BODY claims only allow 7 document types (no guilty_*) const blameForUpload = claimCase.blameRequestId @@ -4174,10 +4294,13 @@ export class ClaimRequestManagementService { throw new BadRequestException(`Document type ${body.documentKey} is not required for CAR_BODY claims`); } - // 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`); + // Initial flow: each document once. Expert resend: allow replacement. + if (!isResendUpload) { + const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey) + ?? (claimCase.requiredDocuments as any)?.[body.documentKey]; + if (existingDoc?.uploaded) { + throw new ConflictException(`Document ${body.documentKey} has already been uploaded`); + } } const fileUrl = buildFileLink(file.path); @@ -4218,59 +4341,86 @@ export class ClaimRequestManagementService { }, }; - // Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY) - const totalDocsRequired = isCarBodyUpload ? 7 : 13; - const currentDocs = claimCase.requiredDocuments || new Map(); - const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; - const allDocumentsUploaded = uploadedCount >= totalDocsRequired; + let allDocumentsUploaded = false; + let remaining = 0; - // If all documents uploaded, user flow complete (captures were done earlier in v2) - if (allDocumentsUploaded) { - updateData['status'] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; - updateData['claimStatus'] = ClaimStatus.PENDING; - updateData['workflow.currentStep'] = - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; - updateData['workflow.nextStep'] = - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; - updateData.$push.history = [ - updateData.$push.history, - { - type: 'STEP_COMPLETED', - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || 'User', - actorType: 'user', + if (!isResendUpload) { + // Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY) + const totalDocsRequired = isCarBodyUpload ? 7 : 13; + const currentDocs = claimCase.requiredDocuments || new Map(); + const uploadedCount = + (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; + allDocumentsUploaded = uploadedCount >= totalDocsRequired; + remaining = totalDocsRequired - uploadedCount; + + // If all documents uploaded, user flow complete (captures were done earlier in v2) + if (allDocumentsUploaded) { + updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + updateData["claimStatus"] = ClaimStatus.PENDING; + updateData["workflow.currentStep"] = + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; + updateData["workflow.nextStep"] = + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + 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", + }, }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - description: 'All required documents uploaded', + { + 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.", + }, }, - }, - { - 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.', - }, - }, - ]; - updateData.$push['workflow.completedSteps'] = - ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; + ]; + updateData.$push["workflow.completedSteps"] = + ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; + } } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData); - const remaining = totalDocsRequired - uploadedCount; + if (isResendUpload) { + await this.tryFinalizeExpertResendAfterUserAction( + claimRequestId, + currentUserId, + claimCase.owner?.fullName || "User", + ); + const refreshed = await this.claimCaseDbService.findById(claimRequestId); + const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt; + return { + claimRequestId: claimCase._id.toString(), + documentKey: body.documentKey, + fileUrl, + allDocumentsUploaded: false, + expertResendComplete, + currentStep: refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND, + message: expertResendComplete + ? "Expert resend requirements are complete. Your claim is back in the damage expert queue." + : "Document uploaded for expert resend.", + }; + } + const message = allDocumentsUploaded - ? 'All documents uploaded successfully. Your claim is now ready for damage expert review.' + ? "All documents uploaded successfully. Your claim is now ready for damage expert review." : `Document uploaded successfully. ${remaining} documents remaining.`; return { @@ -4316,11 +4466,30 @@ export class ClaimRequestManagementService { throw new ForbiddenException('Only the claim owner can capture parts'); } - if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) { + const capStep = claimCase.workflow?.currentStep; + const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND; + if (!isResendCapture && capStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) { throw new BadRequestException( - `Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES}, but current step is ${claimCase.workflow?.currentStep}` + `Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, ); } + if (isResendCapture) { + if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { + throw new BadRequestException( + `Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`, + ); + } + if (body.captureType !== "part") { + throw new BadRequestException( + "During expert resend only damaged-part photos can be uploaded (not car angles).", + ); + } + if (!partKeyAllowedForExpertResend(claimCase, body.captureKey)) { + throw new BadRequestException( + `Part "${body.captureKey}" was not requested by the damage expert for this resend.`, + ); + } + } const fileUrl = buildFileLink(file.path); const captureData = { @@ -4355,11 +4524,43 @@ export class ClaimRequestManagementService { updateData[`media.damagedParts.${body.captureKey}`] = captureData; } + const selectedBefore = (claimCase.damage?.selectedParts || []) as string[]; + if ( + isResendCapture && + body.captureType === "part" && + !selectedBefore.includes(body.captureKey) + ) { + updateData.$addToSet = { "damage.selectedParts": body.captureKey }; + } + const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( claimRequestId, updateData, ); + if (isResendCapture) { + await this.tryFinalizeExpertResendAfterUserAction( + claimRequestId, + currentUserId, + claimCase.owner?.fullName || "User", + ); + const refreshed = await this.claimCaseDbService.findById(claimRequestId); + const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt; + return { + claimRequestId: claimCase._id.toString(), + captureType: body.captureType, + captureKey: body.captureKey, + fileUrl, + allCapturesComplete: expertResendComplete, + expertResendComplete, + currentStep: + refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND, + message: expertResendComplete + ? "Expert resend requirements are complete. Your claim is back in the damage expert queue." + : "Part photo uploaded for expert resend.", + }; + } + const hasCapture = (data: any, key: string) => data && (data instanceof Map ? data.get(key) : data[key]); @@ -4810,7 +5011,14 @@ export class ClaimRequestManagementService { const damagedPartsData = claim.media?.damagedParts as any; const damagedParts: Record = {}; - for (const p of claim.damage?.selectedParts || []) { + const resendPartKeys = normalizeResendPartKeys( + claim.evaluation?.damageExpertResend?.resendCarParts, + ); + const partKeysForDetails = new Set([ + ...((claim.damage?.selectedParts || []) as string[]), + ...resendPartKeys, + ]); + for (const p of partKeysForDetails) { const cap = hasCapture(damagedPartsData, p); damagedParts[p] = { captured: !!cap, @@ -4818,6 +5026,17 @@ export class ClaimRequestManagementService { }; } + const er = claim.evaluation?.damageExpertResend; + const expertResend = + er && (er.resendDescription || (er.resendDocuments?.length ?? 0) > 0 || (er.resendCarParts?.length ?? 0) > 0) + ? { + resendDescription: er.resendDescription, + resendDocuments: normalizeResendDocumentKeys(er.resendDocuments), + resendCarParts: Array.isArray(er.resendCarParts) ? er.resendCarParts : [], + fulfilledAt: er.fulfilledAt, + } + : undefined; + const maskSheba = (s?: string) => s ? s.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3') : undefined; const maskNationalCode = (s?: string) => @@ -4851,6 +5070,7 @@ export class ClaimRequestManagementService { requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined, carAngles, damagedParts, + expertResend, userRating: claim.userRating ? { progressSpeed: claim.userRating.progressSpeed, diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index b750e56..17afc98 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -108,6 +108,37 @@ export class ClaimRequestManagementV2Controller { } } + /** + * V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos). + */ + @Post("request/:claimRequestId/expert-resend/acknowledge") + @ApiOperation({ + summary: "Acknowledge expert resend (instructions only)", + description: + "Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " + + "Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.", + }) + @ApiParam({ name: "claimRequestId" }) + @ApiResponse({ status: 200, description: "Claim returned to expert queue" }) + @ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" }) + async acknowledgeExpertResend( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() user: any, + ) { + try { + return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2( + claimRequestId, + user.sub, + user, + ); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to acknowledge expert resend", + ); + } + } + /** * V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection). */ diff --git a/src/claim-request-management/dto/capture-part-v2.dto.ts b/src/claim-request-management/dto/capture-part-v2.dto.ts index 62c8f83..5e005e2 100644 --- a/src/claim-request-management/dto/capture-part-v2.dto.ts +++ b/src/claim-request-management/dto/capture-part-v2.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; @@ -78,6 +78,11 @@ export class CapturePartV2ResponseDto { example: 'Angle captured successfully. 6 captures remaining.', }) message: string; + + @ApiPropertyOptional({ + description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.', + }) + expertResendComplete?: boolean; } /** diff --git a/src/claim-request-management/dto/claim-details-v2.dto.ts b/src/claim-request-management/dto/claim-details-v2.dto.ts index b61fc36..0ae0e91 100644 --- a/src/claim-request-management/dto/claim-details-v2.dto.ts +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -1,5 +1,20 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +/** Active damage-expert resend request (owner must upload or acknowledge). */ +export class ExpertResendDetailsV2Dto { + @ApiPropertyOptional() + resendDescription?: string; + + @ApiPropertyOptional({ type: [String] }) + resendDocuments?: string[]; + + @ApiPropertyOptional({ type: [Object] }) + resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>; + + @ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' }) + fulfilledAt?: Date; +} + export class ClaimDetailsV2ResponseDto { @ApiProperty({ description: 'Claim case ID' }) claimRequestId: string; @@ -63,6 +78,12 @@ export class ClaimDetailsV2ResponseDto { @ApiPropertyOptional({ description: 'Damaged parts captured' }) damagedParts?: Record; + @ApiPropertyOptional({ + description: + 'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).', + }) + expertResend?: ExpertResendDetailsV2Dto; + @ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' }) userRating?: { progressSpeed: number; diff --git a/src/claim-request-management/dto/upload-document-v2.dto.ts b/src/claim-request-management/dto/upload-document-v2.dto.ts index ade2c06..64c5e68 100644 --- a/src/claim-request-management/dto/upload-document-v2.dto.ts +++ b/src/claim-request-management/dto/upload-document-v2.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; @@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto { example: 'Document uploaded successfully. 12 documents remaining.', }) message: string; + + @ApiPropertyOptional({ + description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.', + }) + expertResendComplete?: boolean; } diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 4d4aa0e..75f36d7 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -156,6 +156,10 @@ export class ClaimResendRequest { @Prop({ type: [MongooseSchema.Types.Mixed], default: [] }) resendCarParts?: any[]; + + /** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */ + @Prop({ type: Date }) + fulfilledAt?: Date; } export const ClaimResendRequestSchema = SchemaFactory.createForClass(ClaimResendRequest); diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index fdb4ddd..3420f8e 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -6,6 +6,7 @@ import { IsBoolean, IsOptional, ValidateNested, + IsEnum, } from 'class-validator'; import { Type } from 'class-transformer'; import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; @@ -72,12 +73,28 @@ export class SubmitExpertReplyV2Dto { } export class ClaimSubmitResendV2Dto { - @ApiProperty({ required: true }) - resendDescription: string; + @ApiPropertyOptional({ + description: + "Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.", + }) + @IsOptional() + @IsString() + resendDescription?: string; - @ApiProperty({ required: true, examples: ClaimRequiredDocumentType }) - resendDocuments: ClaimRequiredDocumentType[]; + @ApiPropertyOptional({ + type: [String], + enum: ClaimRequiredDocumentType, + description: "Extra document keys to upload (may include types not in the initial claim form).", + }) + @IsOptional() + @IsArray() + @IsEnum(ClaimRequiredDocumentType, { each: true }) + resendDocuments?: ClaimRequiredDocumentType[]; - @ApiProperty({ required: true, type: [DamagedPartItem] }) - resendCarParts: DamagedPartItem[]; + @ApiPropertyOptional({ type: [DamagedPartItem] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DamagedPartItem) + resendCarParts?: DamagedPartItem[]; } diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 47d3ad9..b4e11ed 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -58,6 +58,7 @@ import { buildMutualAgreementExpertDecision, enrichBlamePartiesForAgreementView, } from "src/helpers/blame-party-agreement-decision"; +import { resendRequestHasPayload } from "src/helpers/claim-expert-resend"; @Injectable() export class ExpertClaimService { @@ -2069,38 +2070,57 @@ export class ExpertClaimService { throw new ForbiddenException('This claim is locked by another expert'); } + // Block duplicate resend while a prior request is still unfinished (stale EXPERT_REVIEWING + pending resend). + // After a successful submit, status becomes WAITING_FOR_USER_RESEND so this endpoint is not callable until the owner finishes. const existingResend = claim.evaluation?.damageExpertResend; - const resendHasContent = + if ( existingResend && - (String(existingResend.resendDescription || '').trim() !== '' || - (existingResend.resendDocuments?.length ?? 0) > 0 || - (existingResend.resendCarParts?.length ?? 0) > 0); - if (resendHasContent) { - throw new ConflictException('A resend request already exists for this claim'); + !existingResend.fulfilledAt && + resendRequestHasPayload(existingResend) + ) { + throw new ConflictException( + "A resend request is still open for this claim. Wait until the owner completes uploads or contact support if the case is stuck.", + ); + } + + const docs = reply.resendDocuments ?? []; + const parts = reply.resendCarParts ?? []; + const desc = String(reply.resendDescription ?? "").trim(); + if (!desc && docs.length === 0 && parts.length === 0) { + throw new BadRequestException( + "Provide resendDescription and/or resendDocuments and/or resendCarParts.", + ); } await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { $set: { + status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, claimStatus: ClaimStatus.NEEDS_REVISION, - 'workflow.locked': false, - 'evaluation.damageExpertResend': { - resendDescription: reply.resendDescription, - resendDocuments: reply.resendDocuments ?? [], - resendCarParts: reply.resendCarParts ?? [], + "workflow.locked": false, + "workflow.currentStep": ClaimWorkflowStep.USER_EXPERT_RESEND, + "workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + "evaluation.damageExpertResend": { + resendDescription: desc || undefined, + resendDocuments: docs, + resendCarParts: parts, }, }, + $unset: { + "workflow.lockedAt": "", + "workflow.lockedBy": "", + }, $push: { history: { - type: 'EXPERT_RESEND_REQUESTED', + type: "EXPERT_RESEND_REQUESTED", actor: { actorId: new Types.ObjectId(actor.sub), actorName: actor.fullName, - actorType: 'damage_expert', + actorType: "damage_expert", }, timestamp: new Date(), metadata: { - documentCount: reply.resendDocuments?.length ?? 0, - carPartCount: reply.resendCarParts?.length ?? 0, + documentCount: docs.length, + carPartCount: parts.length, }, }, }, @@ -2108,9 +2128,12 @@ export class ExpertClaimService { return { claimRequestId, + status: ClaimCaseStatus.WAITING_FOR_USER_RESEND, claimStatus: ClaimStatus.NEEDS_REVISION, + currentStep: ClaimWorkflowStep.USER_EXPERT_RESEND, + nextStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, message: - 'Resend request recorded. The damaged party can review requirements and submit an objection or updated materials.', + "Resend request recorded. The owner must upload the requested documents and/or part photos (or confirm if only instructions were given). The claim will return to the damage expert queue when complete.", }; } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 6ea55da..72113a3 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -90,9 +90,10 @@ export class ExpertClaimV2Controller { @Put("reply/resend/:claimRequestId") @ApiOperation({ - summary: "Submit expert damage resend request. ", + summary: "Submit expert damage resend request", description: - "Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.", + "Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " + + "The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.", }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: ClaimSubmitResendV2Dto }) diff --git a/src/helpers/claim-expert-resend.ts b/src/helpers/claim-expert-resend.ts new file mode 100644 index 0000000..cce0b35 --- /dev/null +++ b/src/helpers/claim-expert-resend.ts @@ -0,0 +1,105 @@ +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; + +export function resendRequestHasPayload(r: { + resendDescription?: string; + resendDocuments?: unknown[]; + resendCarParts?: unknown[]; +}): boolean { + if (!r) return false; + if (String(r.resendDescription || "").trim() !== "") return true; + if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true; + if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true; + return false; +} + +/** Normalize expert-requested document keys (strings or { key }). */ +export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] { + if (!Array.isArray(docs)) return []; + const keys: string[] = []; + for (const d of docs) { + if (typeof d === "string" && d.trim()) keys.push(d.trim()); + else if (d && typeof d === "object" && "key" in (d as object)) { + const k = String((d as { key?: string }).key || "").trim(); + if (k) keys.push(k); + } + } + return keys; +} + +/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */ +export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] { + if (!Array.isArray(parts)) return []; + const keys: string[] = []; + for (const p of parts) { + if (typeof p === "string" && p.trim()) keys.push(p.trim()); + else if (p && typeof p === "object" && "key" in (p as object)) { + const k = String((p as { key?: string }).key || "").trim(); + if (k) keys.push(k); + } + } + return keys; +} + +function getRequiredDocEntry(claim: any, documentKey: string): unknown { + const rd = claim?.requiredDocuments; + if (!rd) return undefined; + return rd instanceof Map ? rd.get(documentKey) : rd[documentKey]; +} + +export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean { + const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined; + return !!doc?.uploaded; +} + +export function hasDamagedPartCapture(claim: any, partKey: string): boolean { + const data = claim?.media?.damagedParts; + if (!data) return false; + const cap = data instanceof Map ? data.get(partKey) : data[partKey]; + return !!cap; +} + +/** + * True when the user has satisfied every document and part the damage expert asked for, + * or when the expert only left instructions (no doc/part list) and there is a description. + */ +export function canFinalizeExpertResend(claim: any): boolean { + if (!claim) return false; + if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false; + if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false; + + const r = claim.evaluation?.damageExpertResend; + if (!r || r.fulfilledAt) return false; + + const docKeys = normalizeResendDocumentKeys(r.resendDocuments); + const partKeys = normalizeResendPartKeys(r.resendCarParts); + + if (docKeys.length === 0 && partKeys.length === 0) { + return String(r.resendDescription || "").trim() !== ""; + } + + for (const k of docKeys) { + if (!isRequiredDocumentUploaded(claim, k)) return false; + } + for (const k of partKeys) { + if (!hasDamagedPartCapture(claim, k)) return false; + } + return true; +} + +export function documentKeyAllowedForExpertResend( + claim: any, + documentKey: string, +): boolean { + const r = claim?.evaluation?.damageExpertResend; + if (!r || r.fulfilledAt) return false; + const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments)); + return allowed.has(documentKey); +} + +export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean { + const r = claim?.evaluation?.damageExpertResend; + if (!r || r.fulfilledAt) return false; + const allowed = new Set(normalizeResendPartKeys(r.resendCarParts)); + return allowed.has(partKey); +}