forked from Yara724/api
resolved conflicts and maintained new features
This commit is contained in:
@@ -32,6 +32,7 @@ import { MyRequestsDto } from "./dto/my-request.dto";
|
||||
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
|
||||
import { UserCommentDto } from "./dto/user-comment.dto";
|
||||
import { UserObjectionDto } from "./dto/user-objection.dto";
|
||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.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";
|
||||
@@ -41,7 +42,11 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
|
||||
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 {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
VideoCaptureV2ResponseDto,
|
||||
} 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";
|
||||
@@ -2165,6 +2170,172 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase).
|
||||
* Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`.
|
||||
*/
|
||||
async uploadClaimFactorV2(
|
||||
claimRequestId: string,
|
||||
partId: string,
|
||||
file: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
) {
|
||||
try {
|
||||
if (!file) {
|
||||
throw new BadRequestException("A factor file is required for upload.");
|
||||
}
|
||||
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException("Claim not found");
|
||||
}
|
||||
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claim,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException("You do not have access to this claim file");
|
||||
}
|
||||
|
||||
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
||||
? "damageExpertReplyFinal"
|
||||
: "damageExpertReply";
|
||||
const finalReply =
|
||||
replyKey === "damageExpertReplyFinal"
|
||||
? claim.evaluation?.damageExpertReplyFinal
|
||||
: claim.evaluation?.damageExpertReply;
|
||||
|
||||
if (!finalReply?.parts?.length) {
|
||||
throw new BadRequestException("No expert reply found in this claim.");
|
||||
}
|
||||
|
||||
const part = finalReply.parts.find((p) => String(p.partId) === String(partId));
|
||||
if (!part) {
|
||||
throw new BadRequestException(
|
||||
"The specified part was not found in the expert's reply.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!part.factorNeeded) {
|
||||
throw new BadRequestException("This part does not require a factor upload.");
|
||||
}
|
||||
|
||||
if (part.factorLink) {
|
||||
throw new ConflictException(
|
||||
"A factor has already been uploaded for this part.",
|
||||
);
|
||||
}
|
||||
|
||||
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
||||
throw new BadRequestException(
|
||||
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
|
||||
);
|
||||
}
|
||||
|
||||
const partNameForRecord = part.carPartDamage ?? part.partId;
|
||||
|
||||
const factorRecord = await this.claimFactorsImageDbService.create({
|
||||
claimId: new Types.ObjectId(claimRequestId),
|
||||
partId: String(partId),
|
||||
partName: partNameForRecord,
|
||||
path: file.path,
|
||||
fileName: file.filename,
|
||||
uploadedAt: new Date(),
|
||||
});
|
||||
|
||||
const pathPrefix = `evaluation.${replyKey}.parts`;
|
||||
|
||||
const updatedClaim = await this.claimCaseDbService.findOneAndUpdate(
|
||||
{
|
||||
_id: new Types.ObjectId(claimRequestId),
|
||||
[`${pathPrefix}.partId`]: partId,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
[`${pathPrefix}.$.factorLink`]: factorRecord._id,
|
||||
[`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!updatedClaim) {
|
||||
throw new NotFoundException(
|
||||
"Could not find the specific part to update in the database.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId);
|
||||
|
||||
return {
|
||||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||||
factorId: factorRecord._id,
|
||||
url: buildFileLink(file.path),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error during V2 factor upload for claim ${claimRequestId}:`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
"An internal server error occurred during factor upload.",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation).
|
||||
*/
|
||||
private async checkAndTransitionStatusAfterFactorUploadV2(
|
||||
claimRequestId: string,
|
||||
): Promise<void> {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim?.evaluation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalReply =
|
||||
claim.evaluation.damageExpertReplyFinal ||
|
||||
claim.evaluation.damageExpertReply;
|
||||
|
||||
if (!finalReply?.parts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredFactorParts = finalReply.parts.filter((p) => p.factorNeeded === true);
|
||||
if (requiredFactorParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allFactorsAreUploaded = requiredFactorParts.every((p) => !!p.factorLink);
|
||||
if (!allFactorsAreUploaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`,
|
||||
);
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
|
||||
$push: {
|
||||
history: {
|
||||
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
||||
timestamp: new Date(),
|
||||
metadata: { claimRequestId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async checkAndTransitionStatusAfterUpload(
|
||||
claimId: string,
|
||||
): Promise<void> {
|
||||
@@ -4252,6 +4423,288 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Upload car walk-around video (same storage as v1: claim-video-capture + ClaimCase.media.videoCaptureId).
|
||||
*/
|
||||
async setVideoCaptureV2(
|
||||
claimRequestId: string,
|
||||
fileDetail: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<VideoCaptureV2ResponseDto> {
|
||||
try {
|
||||
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 upload car capture video');
|
||||
}
|
||||
|
||||
if (!fileDetail) {
|
||||
throw new BadRequestException('Video file is required.');
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.media?.videoCaptureId) {
|
||||
throw new ConflictException('A video has already been uploaded for this claim.');
|
||||
}
|
||||
|
||||
const videoDoc = await this.createVideoCapture(fileDetail, claimRequestId);
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: { 'media.videoCaptureId': videoDoc._id },
|
||||
$push: {
|
||||
history: {
|
||||
type: 'VIDEO_CAPTURE_UPLOADED',
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
actorType: 'user',
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
videoId: videoDoc._id.toString(),
|
||||
fileName: fileDetail.filename,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
claimRequestId: claimCase._id.toString(),
|
||||
videoId: videoDoc._id.toString(),
|
||||
message: 'Video capture uploaded successfully.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error('setVideoCaptureV2 failed', error);
|
||||
throw new InternalServerErrorException('Failed to upload video capture');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an expert resend request exists (user is expected to respond).
|
||||
*/
|
||||
private claimCaseHasExpertResend(claimCase: any): boolean {
|
||||
const r = claimCase?.evaluation?.damageExpertResend;
|
||||
if (!r) return false;
|
||||
if (typeof r.resendDescription === '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;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: User objection (ClaimCase + evaluation.objection payload).
|
||||
*
|
||||
* Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet.
|
||||
* Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts.
|
||||
*/
|
||||
async handleUserObjectionV2(
|
||||
claimRequestId: string,
|
||||
body: UserObjectionV2Dto,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
) {
|
||||
try {
|
||||
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 submit an objection');
|
||||
}
|
||||
|
||||
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
|
||||
const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply;
|
||||
const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal;
|
||||
|
||||
if (!hasExpertResend && !hasFirstExpertReply) {
|
||||
throw new BadRequestException(
|
||||
'You can only object after the damage expert has submitted an assessment or requested a resend.',
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFinalExpertReply) {
|
||||
throw new ConflictException(
|
||||
'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.',
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.evaluation?.objection?.submittedAt) {
|
||||
throw new ConflictException('An objection has already been submitted for this claim.');
|
||||
}
|
||||
|
||||
const partsIn = body.objectionParts ?? [];
|
||||
const newPartsIn = body.newParts ?? [];
|
||||
if (partsIn.length === 0 && newPartsIn.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'Provide at least one entry in objectionParts and/or newParts.',
|
||||
);
|
||||
}
|
||||
|
||||
const objectionParts = partsIn.map((p) => ({
|
||||
partId: p.partId,
|
||||
reason: p.reason,
|
||||
partPrice: p.partPrice,
|
||||
partSalary: p.partSalary,
|
||||
typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||||
carPartDamage: p.carPartDamage,
|
||||
side: p.side,
|
||||
}));
|
||||
|
||||
const newParts = newPartsIn.map((p) => ({
|
||||
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(),
|
||||
partName: p.partName,
|
||||
side: p.side,
|
||||
}));
|
||||
|
||||
const mergedSelected = [...(claimCase.damage?.selectedParts ?? [])];
|
||||
for (const np of newParts) {
|
||||
const key = np.partName?.trim();
|
||||
if (key && !mergedSelected.includes(key)) {
|
||||
mergedSelected.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const objectionPayload = {
|
||||
objectionParts,
|
||||
newParts,
|
||||
submittedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
'workflow.locked': false,
|
||||
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||||
'evaluation.objection': objectionPayload,
|
||||
'damage.selectedParts': mergedSelected,
|
||||
},
|
||||
$push: {
|
||||
history: {
|
||||
type: 'USER_OBJECTION_SUBMITTED',
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(effectiveUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
actorType: 'user',
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
objectionPartCount: objectionParts.length,
|
||||
newPartCount: newParts.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return objectionPayload;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error('handleUserObjectionV2 failed', error);
|
||||
throw new InternalServerErrorException('Failed to submit objection');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: User satisfaction rating for a completed claim case (same intent as v1 PUT …/request/:id/user-rating).
|
||||
*/
|
||||
async addUserRatingV2(
|
||||
claimRequestId: string,
|
||||
ratingDto: UserRatingDto,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<UserClaimRating> {
|
||||
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 submit a satisfaction rating.',
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
'You can only rate a claim after it has been completed.',
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.userRating?.createdAt) {
|
||||
throw new ConflictException('A rating has already been submitted for this claim.');
|
||||
}
|
||||
|
||||
const score = (n: number) => Number(n);
|
||||
for (const [key, label] of [
|
||||
[ratingDto.progressSpeed, 'progressSpeed'],
|
||||
[ratingDto.registrationEase, 'registrationEase'],
|
||||
[ratingDto.overallEvaluation, 'overallEvaluation'],
|
||||
] as const) {
|
||||
const v = score(key);
|
||||
if (Number.isNaN(v) || v < 0 || v > 5) {
|
||||
throw new BadRequestException(`${label} must be a number between 0 and 5.`);
|
||||
}
|
||||
}
|
||||
|
||||
const userRating: UserClaimRating = {
|
||||
progressSpeed: ratingDto.progressSpeed,
|
||||
registrationEase: ratingDto.registrationEase,
|
||||
overallEvaluation: ratingDto.overallEvaluation,
|
||||
comment: ratingDto.comment,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: { userRating },
|
||||
$push: {
|
||||
history: {
|
||||
type: 'USER_RATING_SUBMITTED',
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(effectiveUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
actorType: 'user',
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userRating;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||||
*/
|
||||
@@ -4398,6 +4851,15 @@ export class ClaimRequestManagementService {
|
||||
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
userRating: claim.userRating
|
||||
? {
|
||||
progressSpeed: claim.userRating.progressSpeed,
|
||||
registrationEase: claim.userRating.registrationEase,
|
||||
overallEvaluation: claim.userRating.overallEvaluation,
|
||||
comment: claim.userRating.comment,
|
||||
createdAt: claim.userRating.createdAt,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: (claim as any).createdAt,
|
||||
updatedAt: (claim as any).updatedAt,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user