1
0
forked from Yara724/api

update the resend request and user side

This commit is contained in:
2026-04-19 17:23:14 +03:30
parent 08a4d754c1
commit fca88bc151
13 changed files with 521 additions and 80 deletions

View File

@@ -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,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<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,

View File

@@ -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).
*/

View File

@@ -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;
}
/**

View File

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

View File

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

View File

@@ -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);