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,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
@@ -25,9 +26,15 @@ 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 } from "./dto/my-claims-v2.dto";
|
||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||
|
||||
@ApiTags("claim-request-management (v2)")
|
||||
@Controller("v2/claim-request-management")
|
||||
@@ -101,6 +108,89 @@ export class ClaimRequestManagementV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||
*/
|
||||
@Put("request/:claimRequestId/objection")
|
||||
@ApiOperation({
|
||||
summary: "Submit user objection (V2)",
|
||||
description:
|
||||
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
|
||||
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
|
||||
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserObjectionV2Dto })
|
||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||
async submitUserObjectionV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UserObjectionV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit objection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
|
||||
*/
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiOperation({
|
||||
summary: "Submit user satisfaction rating (V2)",
|
||||
description:
|
||||
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
|
||||
"Stores scores on `ClaimCase.userRating` (0–5). One submission per case.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||
async addUserRatingV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() ratingDto: UserRatingDto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.addUserRatingV2(
|
||||
claimRequestId,
|
||||
ratingDto,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to save rating",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({
|
||||
name: "blameRequestId",
|
||||
@@ -614,4 +704,129 @@ Returns status of each item (uploaded/captured or not).
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
|
||||
*/
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload factor file for a priced part (V2)",
|
||||
description:
|
||||
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
|
||||
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
|
||||
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
|
||||
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-factors",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
callback(null, `-${unique}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async uploadFactorForPartV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Param("partId") partId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||
claimRequestId,
|
||||
partId,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload factor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
|
||||
*/
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const filename = `claim-video-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Upload car walk-around video (V2)",
|
||||
description:
|
||||
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
|
||||
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
|
||||
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Video uploaded successfully",
|
||||
type: VideoCaptureV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||
async captureVideoCaptureV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<VideoCaptureV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,3 +79,26 @@ export class CapturePartV2ResponseDto {
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for car walk-around video upload (V2)
|
||||
*/
|
||||
export class VideoCaptureV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim case ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the stored video document (claim-video-capture)',
|
||||
example: '507f1f77bcf86cd799439012',
|
||||
})
|
||||
videoId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Video capture uploaded successfully.',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,15 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||
userRating?: {
|
||||
progressSpeed: number;
|
||||
registrationEase: number;
|
||||
overallEvaluation: number;
|
||||
comment?: string;
|
||||
createdAt?: Date;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt: string;
|
||||
|
||||
|
||||
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsOptional, ValidateNested } from "class-validator";
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
|
||||
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
||||
}
|
||||
|
||||
async findOneAndUpdate(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
update: any,
|
||||
options?: { new?: boolean },
|
||||
): Promise<ClaimCaseDocument | null> {
|
||||
return this.claimCaseModel.findOneAndUpdate(filter, update, {
|
||||
new: options?.new !== false,
|
||||
});
|
||||
}
|
||||
|
||||
async find(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
options?: { lean?: boolean; select?: string },
|
||||
|
||||
@@ -87,8 +87,9 @@ export class ClaimExpertReply {
|
||||
export const ClaimExpertReplySchema =
|
||||
SchemaFactory.createForClass(ClaimExpertReply);
|
||||
|
||||
/** One line item the user disputes on an expert-priced part */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjection {
|
||||
export class ClaimUserObjectionPart {
|
||||
@Prop({ type: String, required: true })
|
||||
partId: string;
|
||||
|
||||
@@ -103,9 +104,47 @@ export class ClaimUserObjection {
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
carPartDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjection);
|
||||
export const ClaimUserObjectionPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPart);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionNewPart {
|
||||
@Prop({ type: String })
|
||||
partId?: string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
partName: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionNewPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||
|
||||
/**
|
||||
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||
* Stored on {@link ClaimEvaluation.objection}.
|
||||
*/
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionPayload {
|
||||
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
|
||||
objectionParts?: ClaimUserObjectionPart[];
|
||||
|
||||
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
|
||||
newParts?: ClaimUserObjectionNewPart[];
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
submittedAt?: Date;
|
||||
}
|
||||
export const ClaimUserObjectionPayloadSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimResendRequest {
|
||||
@@ -182,8 +221,8 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimResendRequestSchema })
|
||||
damageExpertResend?: ClaimResendRequest | null;
|
||||
|
||||
@Prop({ type: ClaimUserObjectionSchema })
|
||||
objection?: ClaimUserObjection;
|
||||
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||
objection?: ClaimUserObjectionPayload;
|
||||
|
||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||
userResendDocuments?: ClaimUserResendPayload;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ClaimCaseSnapshotSchema,
|
||||
} from "./claim-case.snapshot.schema";
|
||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||
import { UserClaimRating } from "./claim-request-management.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequiredDocumentRef {
|
||||
@@ -75,6 +76,12 @@ export class ClaimCase {
|
||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
/**
|
||||
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
blameDocumentResendPending?: boolean;
|
||||
|
||||
/**
|
||||
* Link to the blame case that originated this claim.
|
||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||
@@ -133,6 +140,12 @@ export class ClaimCase {
|
||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||
history?: HistoryEvent[];
|
||||
|
||||
/**
|
||||
* User satisfaction rating (damaged party), after the case is completed.
|
||||
*/
|
||||
@Prop({ type: UserClaimRating, required: false })
|
||||
userRating?: UserClaimRating;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
createdByRegistrarId?: Types.ObjectId;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user