forked from Yara724/api
update the resend request and user side
This commit is contained in:
@@ -11,6 +11,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",
|
||||
EXPERT_REVIEWING = "EXPERT_REVIEWING",
|
||||
|
||||
@@ -16,6 +16,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",
|
||||
/** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */
|
||||
|
||||
@@ -2,6 +2,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",
|
||||
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",
|
||||
|
||||
@@ -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<void> {
|
||||
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,11 +4294,14 @@ 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);
|
||||
// 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 {
|
||||
},
|
||||
};
|
||||
|
||||
let allDocumentsUploaded = false;
|
||||
let remaining = 0;
|
||||
|
||||
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;
|
||||
const allDocumentsUploaded = uploadedCount >= totalDocsRequired;
|
||||
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'] =
|
||||
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||
updateData["claimStatus"] = ClaimStatus.PENDING;
|
||||
updateData["workflow.currentStep"] =
|
||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||
updateData['workflow.nextStep'] =
|
||||
updateData["workflow.nextStep"] =
|
||||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||||
updateData.$push.history = [
|
||||
updateData.$push.history,
|
||||
{
|
||||
type: 'STEP_COMPLETED',
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
actorType: 'user',
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: "user",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
description: 'All required documents uploaded',
|
||||
description: "All required documents uploaded",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'STEP_COMPLETED',
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
actorType: 'user',
|
||||
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.',
|
||||
"User submission complete. Claim ready for damage expert review.",
|
||||
},
|
||||
},
|
||||
];
|
||||
updateData.$push['workflow.completedSteps'] =
|
||||
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<string, { captured: boolean; url?: string }> = {};
|
||||
for (const p of claim.damage?.selectedParts || []) {
|
||||
const resendPartKeys = normalizeResendPartKeys(
|
||||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||||
);
|
||||
const partKeysForDetails = new Set<string>([
|
||||
...((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,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
105
src/helpers/claim-expert-resend.ts
Normal file
105
src/helpers/claim-expert-resend.ts
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user