diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts index 317cc6d..6c665f2 100644 --- a/src/Types&Enums/blame-request-management/caseStatus.enum.ts +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -1,19 +1,21 @@ //! NEW export enum CaseStatus { - OPEN = "OPEN", - - WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", - - WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", - - WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", - - WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", - - COMPLETED = "COMPLETED", - - CANCELLED = "CANCELLED", - - AUTO_CLOSED = "AUTO_CLOSED" - } \ No newline at end of file + OPEN = "OPEN", + + WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY", + + WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT", + + WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND", + + WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES", + + COMPLETED = "COMPLETED", + + CANCELLED = "CANCELLED", + + AUTO_CLOSED = "AUTO_CLOSED", + + STOPPED = "STOPPED" +} \ No newline at end of file diff --git a/src/Types&Enums/blame-request-management/resend-item-ui.ts b/src/Types&Enums/blame-request-management/resend-item-ui.ts new file mode 100644 index 0000000..687da2a --- /dev/null +++ b/src/Types&Enums/blame-request-management/resend-item-ui.ts @@ -0,0 +1,41 @@ +import { ResendItemType } from "./resendItemType.enum"; + +/** How the mobile/web client should collect each resend item (no workflow-step manager). */ +export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text"; + +export function getResendItemInputKind(item: string): ResendItemInputKind { + if (item === ResendItemType.VOICE) return "voice"; + if (item === ResendItemType.VIDEO) return "video"; + if (item === ResendItemType.DESCRIPTION) return "text"; + return "document_camera"; +} + +export function buildResendItemsWithUi(requestedItems: string[]) { + return requestedItems.map((item) => ({ + item, + inputKind: getResendItemInputKind(item), + })); +} + +/** Whether a single requested item is satisfied on a party's resend row (after merges). */ +export function isResendPartyItemSatisfied( + item: string, + row: { + uploadedDocuments?: Record; + resendVoiceId?: unknown; + resendVideoId?: unknown; + userTextDescription?: string; + }, +): boolean { + const uploaded = row.uploadedDocuments || {}; + if (item === ResendItemType.DESCRIPTION) { + return !!(row.userTextDescription && String(row.userTextDescription).trim()); + } + if (item === ResendItemType.VOICE) { + return !!row.resendVoiceId; + } + if (item === ResendItemType.VIDEO) { + return !!row.resendVideoId; + } + return !!uploaded[item]; +} diff --git a/src/Types&Enums/blame-request-management/resendItemType.enum.ts b/src/Types&Enums/blame-request-management/resendItemType.enum.ts index f57ad56..df48af2 100644 --- a/src/Types&Enums/blame-request-management/resendItemType.enum.ts +++ b/src/Types&Enums/blame-request-management/resendItemType.enum.ts @@ -9,4 +9,7 @@ export enum ResendItemType { // Media evidence VOICE = "voice", VIDEO = "video", + + /** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */ + DESCRIPTION = "description", } diff --git a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts index 2129b2b..3316a1c 100644 --- a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts +++ b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts @@ -18,6 +18,8 @@ export enum ClaimWorkflowStep { // Expert: Damage assessment EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT", + /** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */ + EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY", EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION", // Insurer approval diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 23e5399..b330aea 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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 { + 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 { @@ -4215,6 +4386,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 { + 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 { + 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). */ @@ -4361,6 +4814,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, }; diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index d91deac..ef5922c 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -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", @@ -613,4 +703,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 { + 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", + ); + } + } } diff --git a/src/claim-request-management/dto/capture-part-v2.dto.ts b/src/claim-request-management/dto/capture-part-v2.dto.ts index afa03a5..62c8f83 100644 --- a/src/claim-request-management/dto/capture-part-v2.dto.ts +++ b/src/claim-request-management/dto/capture-part-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/claim-details-v2.dto.ts b/src/claim-request-management/dto/claim-details-v2.dto.ts index 9496341..b61fc36 100644 --- a/src/claim-request-management/dto/claim-details-v2.dto.ts +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -63,6 +63,15 @@ export class ClaimDetailsV2ResponseDto { @ApiPropertyOptional({ description: 'Damaged parts captured' }) damagedParts?: Record; + @ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' }) + userRating?: { + progressSpeed: number; + registrationEase: number; + overallEvaluation: number; + comment?: string; + createdAt?: Date; + }; + @ApiProperty({ description: 'Created at' }) createdAt: string; diff --git a/src/claim-request-management/dto/user-objection-v2.dto.ts b/src/claim-request-management/dto/user-objection-v2.dto.ts new file mode 100644 index 0000000..fae1d5d --- /dev/null +++ b/src/claim-request-management/dto/user-objection-v2.dto.ts @@ -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[]; +} diff --git a/src/claim-request-management/entites/db-service/claim-case.db.service.ts b/src/claim-request-management/entites/db-service/claim-case.db.service.ts index 669279c..60f23cf 100644 --- a/src/claim-request-management/entites/db-service/claim-case.db.service.ts +++ b/src/claim-request-management/entites/db-service/claim-case.db.service.ts @@ -29,6 +29,16 @@ export class ClaimCaseDbService { return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true }); } + async findOneAndUpdate( + filter: FilterQuery, + update: any, + options?: { new?: boolean }, + ): Promise { + return this.claimCaseModel.findOneAndUpdate(filter, update, { + new: options?.new !== false, + }); + } + async find( filter: FilterQuery, options?: { lean?: boolean; select?: string }, diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index 1eada58..4d4aa0e 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -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; diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 8f92330..2df3ae6 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -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; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 60c0f42..e0a9902 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -30,6 +30,7 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum"; +import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui"; import { buildFileLink } from "src/helpers/urlCreator"; import { toJalaliDateAndTime } from "src/helpers/date-jalali"; import { ResendRequestDto } from "./dto/resend.dto"; @@ -37,6 +38,7 @@ import { readFile } from "fs/promises"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service"; +import { RequestManagementService } from "src/request-management/request-management.service"; interface CheckedRequestEntry { CheckedRequest?: { @@ -63,6 +65,10 @@ function statementToFormKey( @Injectable() export class ExpertBlameService { private readonly logger = new Logger(ExpertBlameService.name); + + /** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */ + private readonly blameV2LockTtlMs = 15 * 60 * 1000; + constructor( private readonly requestManagementDbService: RequestManagementDbService, private readonly blameRequestDbService: BlameRequestDbService, @@ -72,7 +78,8 @@ export class ExpertBlameService { private readonly expertDbService: ExpertDbService, private readonly blameDocumentDbService: BlameDocumentDbService, private readonly userSignDbService: UserSignDbService, - ) {} + private readonly requestManagementService: RequestManagementService, + ) { } async findAll(actor: any): Promise { // 1. Fetch all potentially relevant requests from the database. @@ -193,7 +200,13 @@ export class ExpertBlameService { requireActorClientKey(actor); const expertId = actor.sub; - const allCases = await this.blameRequestDbService.find({}, { lean: true }); + // Fetch all DISAGREEMENT cases + const allCases = await this.blameRequestDbService.find( + { + blameStatus: BlameStatus.DISAGREEMENT, + }, + { lean: true }, + ); // Filter to show only: // 1. Same insurance tenant (party clientId or expert-initiated by this actor) @@ -239,6 +252,35 @@ export class ExpertBlameService { return false; }); + const staleIds = new Set(); + for (const doc of visibleCases) { + const w = doc.workflow as Record | undefined; + if (!w?.locked) continue; + const la = w.lockedAt; + if (!la) { + staleIds.add(String(doc._id)); + continue; + } + if ( + Date.now() >= + new Date(la as string | Date).getTime() + this.blameV2LockTtlMs + ) { + staleIds.add(String(doc._id)); + } + } + await Promise.all( + [...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)), + ); + for (const doc of visibleCases) { + if (!staleIds.has(String(doc._id))) continue; + const w = doc.workflow as Record; + if (w) { + w.locked = false; + delete w.lockedAt; + delete w.lockedBy; + } + } + const items: AllRequestDtoV2[] = visibleCases.map( (doc) => this.mapBlameRequestToListItemV2(doc), ); @@ -294,6 +336,75 @@ export class ExpertBlameService { }; } + /** + * Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}. + * V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now. + */ + private async expireBlameCaseWorkflowLockV2IfStale( + requestId: string, + ): Promise { + const request = await this.blameRequestDbService.findById(requestId); + if (!request?.workflow?.locked) { + return; + } + + const lockedAt = request.workflow.lockedAt; + if ( + lockedAt && + Date.now() < + new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs + ) { + return; + } + + const lockedById = request.workflow.lockedBy?.actorId; + const filter: Record = { + _id: new Types.ObjectId(requestId), + "workflow.locked": true, + }; + if (lockedAt) { + filter["workflow.lockedAt"] = lockedAt; + } else if (lockedById) { + filter["workflow.lockedBy.actorId"] = new Types.ObjectId( + String(lockedById), + ); + } + + const cleared = await this.blameRequestDbService.findOneAndUpdate( + filter as any, + { + $set: { "workflow.locked": false }, + $unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" }, + }, + { new: false }, + ); + + if (!cleared) { + return; + } + + if (!request.expert?.decision && lockedById) { + const rid = new Types.ObjectId(requestId).toString(); + try { + await this.expertDbService.findOneAndUpdate( + { _id: new Types.ObjectId(String(lockedById)) }, + { + $inc: { "requestStats.totalChecked": -1 }, + $pull: { countedRequests: rid } as any, + }, + ); + this.logger.warn( + `Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`, + ); + } catch (e) { + this.logger.warn( + `Expert stat rollback failed after blame lock expiry ${requestId}`, + e, + ); + } + } + } + public unlockApi(request, timer) { return setTimeout(async () => { try { @@ -410,9 +521,9 @@ export class ExpertBlameService { // 2. Initial validation to ensure the expert has access // Check if locked by current expert and lock is still active - const isLockedByCurrentExpert = + const isLockedByCurrentExpert = String(request?.actorLocked?.actorId) === actorId && request.lockFile; - + // Check if lock has expired let isLockExpired = false; if (request.unlockTime) { @@ -420,7 +531,7 @@ export class ExpertBlameService { const now = Date.now(); isLockExpired = now >= unlockTime; } - + if (isLockedByCurrentExpert && !isLockExpired) { // This is the correct expert, and the file is locked to them, which is fine. // They can access it even if they closed the browser and came back. @@ -431,7 +542,7 @@ export class ExpertBlameService { // The file is locked by someone else, or lock expired but status hasn't updated yet // Only block if lock is still active and not by current expert if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) { - throw new BadRequestException("Request is locked by another expert"); + throw new BadRequestException("Request is locked by another expert"); } } @@ -541,7 +652,7 @@ export class ExpertBlameService { ); } - // Access control: Expert-initiated files only visible to the initiating field expert + // Access control const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) { @@ -550,9 +661,6 @@ export class ExpertBlameService { ); } - // Allow if: - // 1. No decision yet (fresh request) - // 2. Decision made by current expert const decision = (doc.expert as any)?.decision; const decidedByExpertId = decision?.decidedByExpertId; if (decidedByExpertId && String(decidedByExpertId) !== actorId) { @@ -561,61 +669,83 @@ export class ExpertBlameService { ); } - const parties = (doc.parties ?? []) as Array<{ - evidence?: { videoId?: string; voices?: string[] }; - [key: string]: unknown; + const parties = (doc.parties ?? []); + + const typedParties = parties as Array<{ + vehicle?: { + inquiry?: { + mapped?: any; // Use 'any' or a more specific type if known + // other inquiry properties + }; + // other vehicle properties + }; + evidence?: { // For the second part of your original error description + videoId?: string | number; + voices?: (string | number)[]; + videoUrl?: string; + voiceUrls?: string[]; + }; + // other party properties }>; - for (const party of parties) { + + // Extract inquiry.mapped for each party if needed, but we will keep the full vehicle object. + for (const party of typedParties) { + const mapped = party?.vehicle?.inquiry?.mapped; + // If you still want this field, you can add it like: + // party.inquiryMapped = mapped ?? null; + // But based on your last request, we are keeping the full vehicle object. + } + + // Evidence (videos + voices) + for (const party of typedParties) { if (!party.evidence) continue; const evidence = party.evidence as Record; + if (evidence.videoId) { - const videoDoc = await this.blameVideoDbService.findById( - String(evidence.videoId), - ); - if (videoDoc?.path) { - evidence.videoUrl = buildFileLink(videoDoc.path); - } + const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); + if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); } + if (evidence.voices && Array.isArray(evidence.voices)) { const voiceUrls: string[] = []; for (const voiceId of evidence.voices) { - const voiceDoc = await this.blameVoiceDbService.findById( - String(voiceId), - ); - if (voiceDoc?.path) { - voiceUrls.push(buildFileLink(voiceDoc.path)); - } + const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId)); + if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path)); } evidence.voiceUrls = voiceUrls; } } - const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); - const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); + // Date formatting + const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date(); + const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date(); + + const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); + doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; - // Exclude mapped inquiry vehicle for both parties from response - for (const party of parties) { - delete party.vehicle; - } + // The line `delete party.vehicle;` has been removed, so vehicle will be included. return doc; } catch (error) { if (error instanceof HttpException) throw error; + this.logger.error( "findOneV2 failed", requestId, error instanceof Error ? error.stack : String(error), ); + throw new InternalServerErrorException( error instanceof Error ? error.message : "Failed to get blame case details", ); } } + async lockRequest(requestId: string, actorDetail) { const existing = await this.requestManagementDbService.findOne(requestId); if (!existing) { @@ -691,8 +821,8 @@ export class ExpertBlameService { async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { try { const now = new Date(); - const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); // First, check the current state const request = await this.blameRequestDbService.findById(requestId); if (!request) { @@ -726,7 +856,8 @@ export class ExpertBlameService { if (request.workflow?.locked) { const lockedAt = request.workflow.lockedAt; if (lockedAt) { - const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; + const lockExpiryTime = + new Date(lockedAt).getTime() + this.blameV2LockTtlMs; if (Date.now() < lockExpiryTime) { // Lock is still valid const lockedByActorId = String( @@ -791,8 +922,19 @@ export class ExpertBlameService { requestId: string, resendDto: ResendRequestDto, actor: any, - ): Promise<{ requestId: string; status: string }> { + ): Promise<{ + requestId: string; + status: string; + parties: Array<{ + partyId: string; + requestedItems: ResendItemType[]; + requestedItemsUi: ReturnType; + description?: string; + }>; + }> { try { + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + requireActorClientKey(actor); const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); @@ -820,7 +962,8 @@ export class ExpertBlameService { // Validate lock hasn't expired const lockedAt = request.workflow?.lockedAt; if (lockedAt) { - const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; + const lockExpiryTime = + new Date(lockedAt).getTime() + this.blameV2LockTtlMs; if (Date.now() > lockExpiryTime) { throw new ForbiddenException( "Your lock time has expired. Please lock the request again.", @@ -842,16 +985,42 @@ export class ExpertBlameService { ); } + const allowedItems = new Set(Object.values(ResendItemType)); + const partyIdsOnFile = new Set( + (request.parties || []) + .map((p) => (p.person?.userId ? String(p.person.userId) : "")) + .filter(Boolean), + ); + + for (const party of resendDto.parties) { + const uid = String(party.partyId); + if (!partyIdsOnFile.has(uid)) { + throw new BadRequestException( + `partyId ${uid} is not a party userId on this blame request.`, + ); + } + for (const item of party.requestedItems || []) { + if (!allowedItems.has(String(item))) { + throw new BadRequestException( + `Invalid requestedItems value: ${item}. Use ResendItemType values.`, + ); + } + } + } + const now = new Date(); - // Build resend requests for parties - const partyResendRequests = resendDto.parties.map((party) => ({ - partyId: new Types.ObjectId(String(party.partyId)), - requestedItems: party.requestedItems, - description: party.description || "", - requestedAt: now, - completed: false, - })); + const partyResendRequests = resendDto.parties.map((party) => { + const uid = String(party.partyId); + return { + partyId: new Types.ObjectId(uid), + requestedItems: party.requestedItems, + description: party.description || "", + requestedAt: now, + completed: false, + uploadedDocuments: {}, + }; + }); const updatePayload = { $set: { @@ -865,9 +1034,6 @@ export class ExpertBlameService { }, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, }, - $push: { - "workflow.completedSteps": "WAITING_FOR_GUILT_DECISION", - }, $unset: { "workflow.lockedAt": "", "workflow.lockedBy": "", @@ -885,11 +1051,21 @@ export class ExpertBlameService { ); } + await this.requestManagementService.applyLinkedClaimsBlameResendStarted( + requestId, + ); + // TODO: Send notifications to parties (SMS/Push) about required documents return { requestId: String(request._id), status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, + parties: partyResendRequests.map((p) => ({ + partyId: String(p.partyId), + requestedItems: p.requestedItems as ResendItemType[], + requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]), + description: p.description || undefined, + })), }; } catch (error) { if (error instanceof HttpException) throw error; @@ -907,6 +1083,12 @@ export class ExpertBlameService { /** * V2: Submit expert reply for blame case (blameCases collection). * Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES. + * + * Note: V1 `replyRequest` also handled an “objection” path (`expertResendReply`) by writing + * `expertSubmitReplyFinal` on the legacy request document. Blame V2 uses a single + * `expert.decision` on `blameCases`; resend is modeled as `expert.resend` *before* the first + * decision. If you need a second decision after signatures/objections, extend the schema + * (e.g. decision revision) and branch here similarly to V1. */ async replyRequestV2( requestId: string, @@ -914,6 +1096,7 @@ export class ExpertBlameService { actor: any, ): Promise<{ requestId: string; status: string }> { try { + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); requireActorClientKey(actor); const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); @@ -945,7 +1128,8 @@ export class ExpertBlameService { // Check if lock has expired (15 minutes) let isLockExpired = false; if (lockedAt) { - const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000; + const lockExpiryTime = + new Date(lockedAt).getTime() + this.blameV2LockTtlMs; isLockExpired = Date.now() > lockExpiryTime; } @@ -1040,6 +1224,126 @@ export class ExpertBlameService { } } + async submitInPerson( + requestId: string, + actorId: string, + ) { + try { + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + const request = await this.blameRequestDbService.findById(requestId); + + if (!request) { + throw new NotFoundException("Request not found"); + } + + // Validate no decision exists yet + if (request.expert?.decision) { + throw new ForbiddenException( + "This request already has an expert decision.", + ); + } + + // Check if request is locked + if (!request.workflow?.locked) { + throw new ForbiddenException( + "You must lock the request before submitting a reply.", + ); + } + + const lockedByActorId = String(request.workflow?.lockedBy?.actorId || ""); + const lockedAt = request.workflow?.lockedAt; + const isLockedByCurrentActor = lockedByActorId === actorId; + + // Check if lock has expired (15 minutes) + let isLockExpired = false; + if (lockedAt) { + const lockExpiryTime = + new Date(lockedAt).getTime() + this.blameV2LockTtlMs; + isLockExpired = Date.now() > lockExpiryTime; + } + + // Handle different lock scenarios + if (!isLockedByCurrentActor) { + // Request is locked by another expert + if (isLockExpired) { + throw new ForbiddenException( + "You must lock the request first before submitting a reply.", + ); + } else { + throw new ForbiddenException( + "This request is currently locked by another expert.", + ); + } + } + + // Current actor is the lock owner + if (isLockExpired) { + throw new ForbiddenException( + "Your lock time has expired. Please lock the request again before submitting.", + ); + } + + const now = new Date(); + + // Build expert decision with fields + const decisionPayload: any = { + guiltyPartyId: null, + description: "حضوری مراجعه شود.", + decidedAt: now, + decidedByExpertId: new Types.ObjectId(actorId), + fields: { + accidentWay: null, + accidentReason: null, + accidentType: null, + }, + }; + + const updatePayload = { + $set: { + "workflow.locked": false, + "workflow.currentStep": "COMPLETED", + "workflow.nextStep": null, + "expert.decision": decisionPayload, + status: CaseStatus.STOPPED, + }, + $push: { + "workflow.completedSteps": "WAITING_FOR_GUILT_DECISION", + }, + $unset: { + "workflow.lockedAt": "", + "workflow.lockedBy": "", + }, + }; + + const updateResult = await this.blameRequestDbService.findByIdAndUpdate( + requestId, + updatePayload, + ); + + if (!updateResult) { + throw new InternalServerErrorException( + "Failed to update request with expert reply", + ); + } + + return { + requestId: String(request._id), + status: CaseStatus.WAITING_FOR_SIGNATURES, + }; + + } catch (error) { + if (error instanceof HttpException) throw error; + this.logger.error( + "submitInPerson failed", + requestId, + error instanceof Error ? error.stack : String(error), + ); + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to submit expert reply", + ); + } + } + private async updateDamageExpertStats( expertId: string, requestId: string, @@ -1112,7 +1416,7 @@ export class ExpertBlameService { "Access denied to this request. You are not the locked expert.", ); } - + // Check if lock has expired (unlockTime has passed) if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); @@ -1123,7 +1427,7 @@ export class ExpertBlameService { } else if (request.unlockTime == null) { throw new ForbiddenException("Your lock time has expired."); } - + if (!request.lockFile) { throw new ForbiddenException( "You must lock the request before submitting a reply.", @@ -1374,4 +1678,5 @@ export class ExpertBlameService { return updated; } + } diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index e372156..708261a 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -24,7 +24,7 @@ import { ResendRequestDto } from "./dto/resend.dto"; @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT) export class ExpertBlameV2Controller { - constructor(private readonly expertBlameService: ExpertBlameService) {} + constructor(private readonly expertBlameService: ExpertBlameService) { } @Get() async findAll(@CurrentUser() actor: any) { @@ -101,4 +101,22 @@ export class ExpertBlameV2Controller { ); } } + + @Put("reply/inPerson/:id") + @ApiParam({ name: "id", description: "Blame case request id" }) + @ApiBody({ type: SubmitReplyDto }) + async submitInPerson( + @Param("id") id: string, + @Body() body: SubmitReplyDto, + @CurrentUser() actor: any, + ) { + try { + return await this.expertBlameService.submitInPerson(id, actor.sub); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to submit expert reply for in person", + ); + } + } } diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index 84f205b..0bdb4bd 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -69,6 +69,21 @@ export class ClaimDetailV2ResponseDto { @ApiPropertyOptional({ description: 'Damaged parts captured' }) damagedParts?: Record; + @ApiPropertyOptional({ + description: + 'True when user uploaded all required factors and the case awaits expert approve/reject.', + }) + awaitingFactorValidation?: boolean; + + @ApiPropertyOptional({ + description: + 'Expert reply payloads (first and/or final) when awaiting factor validation.', + }) + evaluation?: { + damageExpertReply?: unknown; + damageExpertReplyFinal?: unknown; + }; + @ApiProperty() createdAt: string; diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts index 7141e27..9fb1f18 100644 --- a/src/expert-claim/dto/claim-list-v2.dto.ts +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -31,6 +31,12 @@ export class ClaimListItemV2Dto { @ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' }) createdAt: string; + + @ApiPropertyOptional({ + description: + 'True when the claim is in the expert factor validation queue (all factors uploaded).', + }) + awaitingFactorValidation?: boolean; } export class GetClaimListV2ResponseDto { diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts index 2b7d2c7..fdb4ddd 100644 --- a/src/expert-claim/dto/expert-claim-v2.dto.ts +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -8,6 +8,8 @@ import { ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; +import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum'; +import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto'; export class CarPartDamageV2Dto { @ApiProperty({ example: 'left' }) @@ -68,3 +70,14 @@ export class SubmitExpertReplyV2Dto { @Type(() => PartPricingV2Dto) parts: PartPricingV2Dto[]; } + +export class ClaimSubmitResendV2Dto { + @ApiProperty({ required: true }) + resendDescription: string; + + @ApiProperty({ required: true, examples: ClaimRequiredDocumentType }) + resendDocuments: ClaimRequiredDocumentType[]; + + @ApiProperty({ required: true, type: [DamagedPartItem] }) + resendCarParts: DamagedPartItem[]; +} diff --git a/src/expert-claim/dto/factor-validation.dto.ts b/src/expert-claim/dto/factor-validation.dto.ts index bee838a..36336cf 100644 --- a/src/expert-claim/dto/factor-validation.dto.ts +++ b/src/expert-claim/dto/factor-validation.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsArray, @@ -31,3 +31,45 @@ export class FactorValidationDto { @Type(() => FactorDecisionDto) decisions: FactorDecisionDto[]; } + +/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */ +class FactorDecisionV2Dto { + @ApiProperty() + @IsString() + partId: string; + + @ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] }) + @IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED]) + status: FactorStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + rejectionReason?: string; + + @ApiPropertyOptional({ + description: + "Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).", + }) + @IsOptional() + @IsString() + price?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + salary?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + totalPayment?: string; +} + +export class FactorValidationV2Dto { + @ApiProperty({ type: [FactorDecisionV2Dto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FactorDecisionV2Dto) + decisions: FactorDecisionV2Dto[]; +} diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index c3d0332..8734c40 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { HttpService } from "@nestjs/axios"; import { BadRequestException, + ConflictException, ForbiddenException, HttpException, HttpStatus, @@ -47,6 +48,9 @@ import { ClaimRequiredDocumentDbService } from "src/claim-request-management/ent import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; +import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto"; +import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; +import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; @Injectable() export class ExpertClaimService { @@ -159,13 +163,14 @@ export class ExpertClaimService { private readonly claimVideoCaptureDbService: VideoCaptureDbService, private readonly httpService: HttpService, private readonly claimCaseDbService: ClaimCaseDbService, + private readonly blameRequestDbService: BlameRequestDbService, private readonly sandHubService: SandHubService, private readonly damageExpertDbService: DamageExpertDbService, private readonly clientDbService: ClientDbService, private readonly claimFactorsImageDbService: ClaimFactorsImageDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly branchDbService: BranchDbService, - ) {} + ) { } private isVisibleToClientType(client: any, actor: any): boolean { if (actor.userType === UserType.GENUINE) { @@ -489,7 +494,7 @@ export class ExpertClaimService { async calculatePriceDrop(request: any, carPartsAi: any, query?: any) { const requestId = request?._id?.toString() || 'unknown'; - + try { // Step 1: Check SandHub data if (!request?.blameFile?.sanHubId) { @@ -547,7 +552,7 @@ export class ExpertClaimService { } else if (severityValues.length > 0) { // Only fetch car prices if we have severity values to calculate const carPrices = await this.fetchCarPrices(); - + if (!carPrices || carPrices.length === 0) { this.logger.error( `[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`, @@ -557,9 +562,9 @@ export class ExpertClaimService { `[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`, ); - const closestCar = this.findClosestCar( - request.carDetail.carName, - carPrices, + const closestCar = this.findClosestCar( + request.carDetail.carName, + carPrices, ); if (!closestCar) { @@ -573,11 +578,11 @@ export class ExpertClaimService { } else { this.logger.debug( `[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`, - ); - autoCalculatedData = { + ); + autoCalculatedData = { carPrice: closestCar.marketPrice, - carValue: severityValues, - }; + carValue: severityValues, + }; } } } @@ -615,10 +620,10 @@ export class ExpertClaimService { } // Step 6: Save to database - await this.claimRequestManagementDbService.findOneAndUpdate( - { _id: new Types.ObjectId(request._id) }, - { $set: { priceDrop: finalPriceDropData } }, - ); + await this.claimRequestManagementDbService.findOneAndUpdate( + { _id: new Types.ObjectId(request._id) }, + { $set: { priceDrop: finalPriceDropData } }, + ); this.logger.log( `[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`, @@ -686,7 +691,7 @@ export class ExpertClaimService { const severityValue = this.priceDropPart[originalPriceDropKey]?.[ - damagedPartInfo.severity + damagedPartInfo.severity ]; if (severityValue !== undefined) { @@ -983,13 +988,13 @@ export class ExpertClaimService { private isCurrentUserAllowed(request: any, currentUser: any): boolean { // Check if locked by current user and lock is still active - const isLockedByCurrentUser = + const isLockedByCurrentUser = String(request?.actorLocked?.actorId) === currentUser.sub && request.lockFile; - + if (!isLockedByCurrentUser) { return false; - } + } // Also check if lock has expired (unlockTime has passed) if (request.unlockTime) { @@ -1001,7 +1006,7 @@ export class ExpertClaimService { return true; } } - + return true; } @@ -1009,7 +1014,7 @@ export class ExpertClaimService { if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) { return false; } - + // Check if lock has expired if (request.unlockTime) { const unlockTime = new Date(request.unlockTime).getTime(); @@ -1019,15 +1024,15 @@ export class ExpertClaimService { return false; } } - + // If currentUser is provided, allow access if they are the one who locked it if (currentUser) { - const isLockedByCurrentUser = + const isLockedByCurrentUser = String(request?.actorLocked?.actorId) === currentUser.sub; // Return false (not locked) if locked by current user, true if locked by someone else return !isLockedByCurrentUser; } - + // If no currentUser provided, treat as locked return true; } @@ -1050,18 +1055,18 @@ export class ExpertClaimService { ) { throw new ForbiddenException("Access denied to this request"); } - + // Check if lock has expired (unlockTime has passed) if (request?.unlockTime && !request?.objection) { const unlockTime = new Date(request.unlockTime).getTime(); const now = Date.now(); if (now >= unlockTime) { - throw new ForbiddenException("Your time has expired"); - } + throw new ForbiddenException("Your time has expired"); + } } else if (request?.unlockTime == null && !request?.objection) { throw new ForbiddenException("Your time has expired"); } - + if (!request.lockFile && !request?.objection) { throw new ForbiddenException( "For submit reply you must lock the request", @@ -1270,6 +1275,66 @@ export class ExpertClaimService { return request; } + async streamServiceV2(requestId, query, res, header) { + const request = await this.claimCaseDbService.findById(requestId); + console.log(request) + // const blameCase = await this.blameCaseDbService + return request; + // let videoPath: string = ""; + // switch (query) { + // case "accident": + // videoPath = await this.getAccidentVideoPath( + // String( + // request?.blameFile?.firstPartyDetails?.firstPartyFile + // ?.firstPartyVideoId, + // ), + // ); + // break; + // case "car-capture": + // videoPath = await this.getCarCapture(requestId); + // break; + // default: + // throw new NotFoundException("Invalid query type"); + // } + // if (!videoPath) throw new NotFoundException("video_not_found"); + + // const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, "")); + + // if (!existsSync(absolutePath)) { + // throw new NotFoundException( + // `Video file not found at path: ${absolutePath}`, + // ); + // } + + // const { size } = statSync(absolutePath); + // const range = header.range; + + // if (range) { + // const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); + // const start = parseInt(startStr, 10); + // const end = endStr ? parseInt(endStr, 10) : size - 1; + // const chunkSize = end - start + 1; + + // const file = createReadStream(absolutePath, { start, end }); + + // res.writeHead(206, { + // "Content-Range": `bytes ${start}-${end}/${size}`, + // "Accept-Ranges": "bytes", + // "Content-Length": chunkSize, + // "Content-Type": "video/mp4", + // }); + + // file.pipe(res); + // } else { + // res.writeHead(200, { + // "Content-Length": size, + // "Content-Type": "video/mp4", + // }); + + // createReadStream(absolutePath).pipe(res); + // } + } + async streamService(requestId, query, res, header) { const request = await this.claimRequestManagementDbService.findOne(requestId); @@ -1543,6 +1608,210 @@ export class ExpertClaimService { }; } + /** + * V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply. + * Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION. + * — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW. + * — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization). + */ + async validateClaimFactorsV2( + claimRequestId: string, + body: FactorValidationV2Dto, + actor: { sub: string; fullName?: string; clientKey?: string }, + ) { + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim) { + throw new NotFoundException("Claim request not found"); + } + + assertClaimCaseForTenant(claim, actor); + + if ( + claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL || + claim.claimStatus !== ClaimStatus.UNDER_REVIEW || + claim.workflow?.currentStep !== ClaimWorkflowStep.EXPERT_COST_EVALUATION + ) { + throw new BadRequestException( + "Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).", + ); + } + + const replyField = claim.evaluation?.damageExpertReplyFinal + ? "damageExpertReplyFinal" + : "damageExpertReply"; + const finalReply = + replyField === "damageExpertReplyFinal" + ? claim.evaluation?.damageExpertReplyFinal + : claim.evaluation?.damageExpertReply; + + if (!finalReply?.parts?.length) { + throw new BadRequestException("No expert reply found in this claim."); + } + + const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded); + if (requiredFactors.length === 0) { + throw new BadRequestException("No parts require factor validation."); + } + if (!requiredFactors.every((p) => !!p.factorLink)) { + throw new BadRequestException( + "Not all required factors have been uploaded yet.", + ); + } + + const $set: Record = {}; + for (const decision of body.decisions) { + const partIndex = finalReply.parts.findIndex( + (p) => String(p.partId) === String(decision.partId), + ); + if (partIndex === -1) { + this.logger.warn( + `Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`, + ); + continue; + } + const part = finalReply.parts[partIndex]; + if (!part.factorNeeded) { + this.logger.warn( + `Part ${decision.partId} is not factor-needed, skipping.`, + ); + continue; + } + + const base = `evaluation.${replyField}.parts.${partIndex}`; + $set[`${base}.factorStatus`] = decision.status; + $set[`${base}.rejectionReason`] = decision.rejectionReason ?? null; + if (decision.price !== undefined) { + $set[`${base}.price`] = decision.price; + } + if (decision.salary !== undefined) { + $set[`${base}.salary`] = decision.salary; + } + if (decision.totalPayment !== undefined) { + $set[`${base}.totalPayment`] = decision.totalPayment; + } + + if (decision.status === FactorStatus.REJECTED) { + const hasOverride = + decision.totalPayment != null || + (decision.price != null && decision.salary != null); + if (!hasOverride) { + throw new BadRequestException( + `Part ${decision.partId}: rejected factors require expert totalPayment (or both price and salary).`, + ); + } + } + } + + if (Object.keys($set).length === 0) { + throw new BadRequestException("No valid factor decisions to apply."); + } + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set, + }); + + const updatedClaim = await this.claimCaseDbService.findById(claimRequestId); + const updatedReply = + replyField === "damageExpertReplyFinal" + ? updatedClaim!.evaluation!.damageExpertReplyFinal! + : updatedClaim!.evaluation!.damageExpertReply!; + + const reqParts = updatedReply.parts.filter((p) => p.factorNeeded); + const anyRejected = reqParts.some( + (p) => p.factorStatus === FactorStatus.REJECTED, + ); + const anyStillPending = reqParts.some( + (p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING, + ); + + const baseMessage = "Factor validation updated."; + if (anyStillPending) { + return { + message: `${baseMessage} Some factors are still pending review.`, + claimRequestId, + claimStatus: updatedClaim!.claimStatus, + caseStatus: updatedClaim!.status, + pendingFactorValidation: true, + }; + } + + const PRICE_CAP = 30_000_000; + let totalPrice = 0; + for (const part of updatedReply.parts || []) { + const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0")); + if (!isNaN(parsed)) { + totalPrice += parsed; + } + } + if (totalPrice > PRICE_CAP) { + throw new BadRequestException({ + message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`, + error: "PRICE_CAP_ERROR", + totalPrice, + priceCap: PRICE_CAP, + }); + } + + const historyActor = { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: "damage_expert", + }; + + if (anyRejected) { + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + status: ClaimCaseStatus.COMPLETED, + claimStatus: ClaimStatus.APPROVED, + "workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED, + "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, + }, + $push: { + history: { + type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED", + actor: historyActor, + timestamp: new Date(), + metadata: { replyField }, + }, + }, + }); + + return { + message: + "Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.", + claimRequestId, + claimStatus: ClaimStatus.APPROVED, + caseStatus: ClaimCaseStatus.COMPLETED, + outcome: "REJECTED_REPRICED_COMPLETED", + }; + } + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + claimStatus: ClaimStatus.APPROVED, + "workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW, + "workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED, + }, + $push: { + history: { + type: "FACTORS_VALIDATED_ALL_APPROVED", + actor: historyActor, + timestamp: new Date(), + metadata: { replyField }, + }, + }, + }); + + return { + message: + "All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).", + claimRequestId, + claimStatus: ClaimStatus.APPROVED, + caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + outcome: "ALL_APPROVED_INSURER_REVIEW", + }; + } + async inPersonVisit(requestId: string, actorDetail: any) { const request = await this.claimRequestManagementDbService.findOne(requestId); @@ -1653,6 +1922,80 @@ export class ExpertClaimService { }; } + async submitResendDocsV2( + claimRequestId: string, + reply: ClaimSubmitResendV2Dto, + actor: any, + ) { + const claim = await this.claimCaseDbService.findById(claimRequestId); + + if (!claim) { + throw new NotFoundException('Claim request not found'); + } + + assertClaimCaseForTenant(claim, actor); + + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { + throw new BadRequestException( + `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, + ); + } + + if (!claim.workflow?.locked) { + throw new ForbiddenException( + 'You must lock the claim before submitting a resend request', + ); + } + + if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) { + throw new ForbiddenException('This claim is locked by another expert'); + } + + const existingResend = claim.evaluation?.damageExpertResend; + const resendHasContent = + 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'); + } + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: { + claimStatus: ClaimStatus.NEEDS_REVISION, + 'workflow.locked': false, + 'evaluation.damageExpertResend': { + resendDescription: reply.resendDescription, + resendDocuments: reply.resendDocuments ?? [], + resendCarParts: reply.resendCarParts ?? [], + }, + }, + $push: { + history: { + type: 'EXPERT_RESEND_REQUESTED', + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: 'damage_expert', + }, + timestamp: new Date(), + metadata: { + documentCount: reply.resendDocuments?.length ?? 0, + carPartCount: reply.resendCarParts?.length ?? 0, + }, + }, + }, + }); + + return { + claimRequestId, + claimStatus: ClaimStatus.NEEDS_REVISION, + message: + 'Resend request recorded. The damaged party can review requirements and submit an objection or updated materials.', + }; + } + /** * V2: Submit damage expert reply for a claim. * @@ -1716,6 +2059,23 @@ export class ExpertClaimService { const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false; + const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt; + const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal; + + if (objectionSubmitted && hasFinalReply) { + throw new ConflictException( + 'A final expert reply after objection already exists for this claim.', + ); + } + + const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply; + const replyField = isFinalReplyAfterObjection + ? 'damageExpertReplyFinal' + : 'damageExpertReply'; + const completedStep = isFinalReplyAfterObjection + ? ClaimWorkflowStep.EXPERT_FINAL_REPLY + : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + const replyPayload = { description: reply.description, parts: reply.parts, @@ -1735,7 +2095,7 @@ export class ExpertClaimService { ? ClaimStatus.NEEDS_REVISION : ClaimStatus.APPROVED; - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + const updatePayload: Record = { status: nextCaseStatus, claimStatus: nextClaimStatus, 'workflow.locked': false, @@ -1743,18 +2103,30 @@ export class ExpertClaimService { 'workflow.nextStep': needsFactorUpload ? ClaimWorkflowStep.INSURER_REVIEW : ClaimWorkflowStep.CLAIM_COMPLETED, - 'evaluation.damageExpertReply': replyPayload, + [`evaluation.${replyField}`]: replyPayload, $push: { - 'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + 'workflow.completedSteps': completedStep, history: { - event: 'EXPERT_REPLY_SUBMITTED', - performedBy: actor.sub, - performedByName: actor.fullName, - performedAt: new Date(), - note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`, + type: isFinalReplyAfterObjection + ? 'EXPERT_FINAL_REPLY_SUBMITTED' + : 'EXPERT_REPLY_SUBMITTED', + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: actor.fullName, + actorType: 'damage_expert', + }, + timestamp: new Date(), + metadata: { + factorNeeded: needsFactorUpload, + partsCount: reply.parts?.length ?? 0, + isFinalReplyAfterObjection, + replyField, + }, }, }, - }); + }; + + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload); return { claimRequestId, @@ -1762,6 +2134,7 @@ export class ExpertClaimService { claimStatus: nextClaimStatus, currentStep: nextStep, factorNeeded: needsFactorUpload, + isFinalReplyAfterObjection, }; } @@ -1834,10 +2207,10 @@ export class ExpertClaimService { /** * V2: Get claim list for damage expert * - * Returns claims that are: - * 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick) - * 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING) - * 3. Any status locked by THIS expert (their own in-progress work) + * Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`): + * 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue) + * 2. Locked by this expert (in-progress) + * 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue) */ async getClaimListV2(actor: any): Promise { requireActorClientKey(actor); @@ -1855,32 +2228,45 @@ export class ExpertClaimService { 'workflow.locked': true, 'workflow.lockedBy.actorId': new Types.ObjectId(actorId), }, + // User uploaded all factors; expert must approve/reject (unlocked queue) + { + status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + claimStatus: ClaimStatus.UNDER_REVIEW, + 'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION, + }, ], }); const list = (claims as any[]) .filter((c) => claimCaseTouchesClient(c, clientKey)) - .map((c) => ({ - claimRequestId: c._id.toString(), - publicId: c.publicId, - status: c.status, - currentStep: c.workflow?.currentStep || '', - locked: c.workflow?.locked || false, - lockedBy: c.workflow?.lockedBy - ? { - actorId: c.workflow.lockedBy.actorId?.toString(), - actorName: c.workflow.lockedBy.actorName, - } - : undefined, - vehicle: c.vehicle - ? { - carName: c.vehicle.carName, - carModel: c.vehicle.carModel, - carType: c.vehicle.carType, - } - : undefined, - createdAt: c.createdAt, - })) as ClaimListItemV2Dto[]; + .map((c) => { + const awaitingFactorValidation = + c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + c.claimStatus === ClaimStatus.UNDER_REVIEW && + c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; + return { + claimRequestId: c._id.toString(), + publicId: c.publicId, + status: c.status, + currentStep: c.workflow?.currentStep || '', + locked: c.workflow?.locked || false, + lockedBy: c.workflow?.lockedBy + ? { + actorId: c.workflow.lockedBy.actorId?.toString(), + actorName: c.workflow.lockedBy.actorName, + } + : undefined, + vehicle: c.vehicle + ? { + carName: c.vehicle.carName, + carModel: c.vehicle.carModel, + carType: c.vehicle.carType, + } + : undefined, + createdAt: c.createdAt, + awaitingFactorValidation, + }; + }) as ClaimListItemV2Dto[]; return { list, total: list.length }; } @@ -1889,15 +2275,14 @@ export class ExpertClaimService { * V2: Get claim detail for damage expert * * Validations: - * - Claim must exist - * - Claim status must be WAITING_FOR_DAMAGE_EXPERT - * - If claim is locked, only the locking expert can access it + * - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`) + * - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) + * - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION */ async getClaimDetailV2( claimRequestId: string, actor: any, ): Promise { - requireActorClientKey(actor); const actorId = actor.sub; const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -1907,14 +2292,22 @@ export class ExpertClaimService { assertClaimCaseForTenant(claim, actor); - if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { + const isPickupReview = + claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + const isFactorValidationPending = + claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + claim.claimStatus === ClaimStatus.UNDER_REVIEW && + claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; + + if (!isPickupReview && !isFactorValidationPending) { throw new ForbiddenException( `This claim is not available for expert review. Current status: ${claim.status}`, ); } - // If locked by someone else, deny access + // If locked by someone else, deny access (initial pickup / assessment only) if ( + isPickupReview && claim.workflow?.locked && claim.workflow.lockedBy?.actorId?.toString() !== actorId ) { @@ -1977,16 +2370,16 @@ export class ExpertClaimService { locked: claim.workflow?.locked || false, lockedBy: claim.workflow?.lockedBy ? { - actorId: claim.workflow.lockedBy.actorId?.toString(), - actorName: claim.workflow.lockedBy.actorName, - lockedAt: (claim.workflow as any).lockedAt?.toISOString(), - } + actorId: claim.workflow.lockedBy.actorId?.toString(), + actorName: claim.workflow.lockedBy.actorName, + lockedAt: (claim.workflow as any).lockedAt?.toISOString(), + } : undefined, owner: claim.owner ? { - userId: claim.owner.userId?.toString(), - fullName: claim.owner.fullName, - } + userId: claim.owner.userId?.toString(), + fullName: claim.owner.fullName, + } : undefined, vehicle: claim.vehicle, blameRequestId: claim.blameRequestId?.toString(), @@ -1999,6 +2392,13 @@ export class ExpertClaimService { : undefined, carAngles, damagedParts, + awaitingFactorValidation: isFactorValidationPending, + evaluation: isFactorValidationPending + ? { + damageExpertReply: (claim as any).evaluation?.damageExpertReply, + damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal, + } + : undefined, createdAt: (claim as any).createdAt, updatedAt: (claim as any).updatedAt, }; diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 2883c0d..1ef92d2 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common"; +import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common"; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiPropertyOptional, + ApiQuery, ApiTags, } from "@nestjs/swagger"; import { IsOptional, IsString } from "class-validator"; @@ -14,8 +15,9 @@ import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { ExpertClaimService } from "./expert-claim.service"; -import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; +import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; +import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; class InPersonVisitV2Dto { @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @@ -30,13 +32,13 @@ class InPersonVisitV2Dto { @UseGuards(LocalActorAuthGuard, RolesGuard) @Roles(RoleEnum.DAMAGE_EXPERT) export class ExpertClaimV2Controller { - constructor(private readonly expertClaimService: ExpertClaimService) {} + constructor(private readonly expertClaimService: ExpertClaimService) { } @Get("requests") @ApiOperation({ summary: "List available claim requests for damage expert", description: - "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.", + "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).", }) async getClaimListV2(@CurrentUser() actor) { return await this.expertClaimService.getClaimListV2(actor); @@ -46,7 +48,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Get claim request detail for damage expert", description: - "Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.", + "Returns full claim details including captured images, required documents status, and damage selections. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation (WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION); in the latter case `evaluation` includes the active expert reply for factors.", }) @ApiParam({ name: "claimRequestId" }) async getClaimDetailV2( @@ -86,6 +88,22 @@ export class ExpertClaimV2Controller { return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor); } + @Put("reply/resend/:claimRequestId") + @ApiOperation({ + 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.", + }) + @ApiParam({ name: "claimRequestId" }) + @ApiBody({ type: ClaimSubmitResendV2Dto }) + async submitExpertResendV2( + @Param("claimRequestId") claimRequestId: string, + @Body() body: ClaimSubmitResendV2Dto, + @CurrentUser() actor, + ) { + return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor); + } + @Patch(":claimRequestId/visit") @ApiOperation({ summary: "Request in-person visit for claim", @@ -106,6 +124,26 @@ export class ExpertClaimV2Controller { ); } + @Patch("validate-factors/:claimRequestId") + @ApiOperation({ + summary: "Validate uploaded repair factors (V2 ClaimCase)", + description: + "After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.", + }) + @ApiParam({ name: "claimRequestId" }) + @ApiBody({ type: FactorValidationV2Dto }) + async validateClaimFactorsV2( + @Param("claimRequestId") claimRequestId: string, + @Body() body: FactorValidationV2Dto, + @CurrentUser() actor, + ) { + return await this.expertClaimService.validateClaimFactorsV2( + claimRequestId, + body, + actor, + ); + } + @Patch("request/:claimRequestId/damaged-parts") @ApiOperation({ summary: "Edit selected damaged parts (V2)", @@ -125,4 +163,21 @@ export class ExpertClaimV2Controller { actor, ); } + + @ApiQuery({ + name: "query", + enum: ["car-capture", "accident"], + required: true, + }) + @Get("stream/:id/video") + @Header("Accept-Ranges", "bytes") + @Header("Content-Type", "video/mp4") + async claimStream( + @Headers() headers, + @Param("id") id: string, + @Query("query") query: "car-capture" | "accident", + @Res() res, + ) { + return this.expertClaimService.streamServiceV2(id, query, res, headers); + } } diff --git a/src/request-management/dto/user-resend.dto.ts b/src/request-management/dto/user-resend.dto.ts index 94805b1..46f45b6 100644 --- a/src/request-management/dto/user-resend.dto.ts +++ b/src/request-management/dto/user-resend.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class UserResendResponseDto { @ApiProperty({ @@ -24,6 +24,22 @@ export class UserResendResponseDto { description: "Whether the user has completed uploading all requested items", }) completed: boolean; + + @ApiPropertyOptional({ + description: + "Per-item UI hint: document_camera (open camera), voice, video, or text (multiline field).", + example: [ + { item: "drivingLicense", inputKind: "document_camera" }, + { item: "description", inputKind: "text" }, + ], + }) + requestedItemsUi?: { item: string; inputKind: string }[]; + + @ApiPropertyOptional({ + description: "Requested items this party has not yet satisfied (partial resend).", + example: ["carCertificate"], + }) + remainingItems?: string[]; } export class UserResendUploadResponseDto { diff --git a/src/request-management/entities/schema/expert-section.type.ts b/src/request-management/entities/schema/expert-section.type.ts index 6cdb20d..1b60704 100644 --- a/src/request-management/entities/schema/expert-section.type.ts +++ b/src/request-management/entities/schema/expert-section.type.ts @@ -64,6 +64,10 @@ export class PartyResendRequest { @Prop({ type: [String], default: [] }) requestedItems: string[]; // Array of ResendItemType values + /** @deprecated Resend UI uses requestedItems + inputKind from API; do not rely on workflow steps. */ + @Prop({ type: [String], default: [] }) + workflowStepKeys?: string[]; + @Prop() description?: string; @@ -75,6 +79,20 @@ export class PartyResendRequest { @Prop({ type: Date }) completedAt?: Date; + + /** Document resend uploads keyed by ResendItemType (e.g. drivingLicense). */ + @Prop({ type: Object, default: {} }) + uploadedDocuments?: Record; + + @Prop({ type: Types.ObjectId }) + resendVoiceId?: Types.ObjectId; + + @Prop({ type: Types.ObjectId }) + resendVideoId?: Types.ObjectId; + + /** Fulfills requestedItems `description` (text). */ + @Prop({ type: String }) + userTextDescription?: string; } export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest); @@ -89,6 +107,12 @@ export class ExpertResend { @Prop({ type: Types.ObjectId }) requestedByExpertId?: Types.ObjectId; + + /** + * @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind. + */ + @Prop({ type: [String], default: [] }) + combinedResubmitStepKeys?: string[]; } export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend); diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index c948130..c6a27c5 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -26,6 +26,13 @@ import { RequestManagementDbService } from "src/request-management/entities/db-s import { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto"; import { UserSignatureResponseDto } from "./dto/user-signature.dto"; import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum"; +import { + buildResendItemsWithUi, + isResendPartyItemSatisfied, +} from "src/Types&Enums/blame-request-management/resend-item-ui"; +import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -304,8 +311,67 @@ export class RequestManagementService { private readonly workflowStepDbService: WorkflowStepDbService, private readonly hashService: HashService, private readonly userAuthService: UserAuthService, + private readonly claimCaseDbService: ClaimCaseDbService, ) { } + /** + * Linked claim cases: block user on damage flow while blame awaits document resend. + */ + async applyLinkedClaimsBlameResendStarted(blameRequestId: string): Promise { + const oid = new Types.ObjectId(blameRequestId); + const claims = await this.claimCaseDbService.find({ + blameRequestId: oid, + }); + for (const c of claims as any[]) { + const st = c.status as ClaimCaseStatus; + if ( + st === ClaimCaseStatus.COMPLETED || + st === ClaimCaseStatus.CANCELLED || + st === ClaimCaseStatus.REJECTED + ) { + continue; + } + await this.claimCaseDbService.findByIdAndUpdate(c._id, { + $set: { + blameDocumentResendPending: true, + claimStatus: ClaimStatus.NEEDS_REVISION, + }, + $push: { + history: { + type: "BLAME_DOCUMENT_RESEND_STARTED", + timestamp: new Date(), + metadata: { blameRequestId }, + }, + }, + }); + } + } + + async applyLinkedClaimsBlameResendCleared(blameRequestId: string): Promise { + const oid = new Types.ObjectId(blameRequestId); + const claims = await this.claimCaseDbService.find({ + blameRequestId: oid, + }); + for (const c of claims as any[]) { + if (!c.blameDocumentResendPending) { + continue; + } + await this.claimCaseDbService.findByIdAndUpdate(c._id, { + $set: { + blameDocumentResendPending: false, + claimStatus: ClaimStatus.PENDING, + }, + $push: { + history: { + type: "BLAME_DOCUMENT_RESEND_CLEARED", + timestamp: new Date(), + metadata: { blameRequestId }, + }, + }, + }); + } + } + async createRequestV2(user: any, type: BlameRequestType) { const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); if (!firstStep?.stepKey) { @@ -5678,11 +5744,27 @@ export class RequestManagementService { ); } + const items = (userResendRequest.requestedItems || []) as string[]; + const row = userResendRequest as any; + const merged = { + uploadedDocuments: { + ...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments + ? { ...row.uploadedDocuments } + : {}), + }, + resendVoiceId: row.resendVoiceId, + resendVideoId: row.resendVideoId, + userTextDescription: row.userTextDescription, + }; + const remainingItems = items.filter((i) => !isResendPartyItemSatisfied(i, merged)); + return { requestId: String(request._id), requestedItems: userResendRequest.requestedItems, description: userResendRequest.description || "", completed: userResendRequest.completed || false, + requestedItemsUi: buildResendItemsWithUi(items), + remainingItems, }; } @@ -5700,6 +5782,7 @@ export class RequestManagementService { voice?: Express.Multer.File[]; video?: Express.Multer.File[]; }, + textDescription?: string, ): Promise { const request = await this.blameRequestDbService.findById(requestId); if (!request) { @@ -5723,7 +5806,9 @@ export class RequestManagementService { throw new ForbiddenException("You are not a party in this request"); } - const partyIndex = parties.findIndex((p) => String(p.person?.userId) === userId); + const partyIndex = parties.findIndex( + (p) => String(p.person?.userId) === String(userId), + ); if (partyIndex === -1) { throw new ForbiddenException("Party not found in request"); } @@ -5743,50 +5828,85 @@ export class RequestManagementService { const userResendRequest = resendParties[resendIndex]; const requestedItems = userResendRequest.requestedItems || []; - // Validate uploaded files match requested items + const row = userResendRequest as any; + const mergedRow = { + uploadedDocuments: { + ...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments + ? { ...row.uploadedDocuments } + : {}), + }, + resendVoiceId: row.resendVoiceId, + resendVideoId: row.resendVideoId, + userTextDescription: row.userTextDescription, + }; + + const hasAnyFile = Object.keys(files || {}).some((fieldName) => { + const fileArray = files[fieldName as keyof typeof files]; + return fileArray && fileArray.length > 0 && fileArray[0]; + }); + const descTrim = + textDescription != null ? String(textDescription).trim() : ""; + const sendingDescription = + requestedItems.includes(ResendItemType.DESCRIPTION) && descTrim.length > 0; + + if (!hasAnyFile && !sendingDescription) { + throw new BadRequestException( + "Provide at least one requested file or a text description when description was requested.", + ); + } + const uploadedItems: string[] = []; const updatePayload: any = { $set: {} }; + if (sendingDescription) { + updatePayload.$set[ + `expert.resend.parties.${resendIndex}.userTextDescription` + ] = descTrim; + mergedRow.userTextDescription = descTrim; + uploadedItems.push(ResendItemType.DESCRIPTION); + } + for (const fieldName in files) { - const fileArray = files[fieldName]; + const fileArray = files[fieldName as keyof typeof files]; if (!fileArray || fileArray.length === 0) continue; const file = fileArray[0]; - // Check if this item was requested if (!requestedItems.includes(fieldName)) { throw new BadRequestException( `${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`, ); } - // Handle different file types if (fieldName === "voice") { - // Create voice record const voiceDoc = await this.blameVoiceDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, context: "EXPERT_RESEND" as any, }); - // Add voice to party evidence updatePayload.$addToSet = updatePayload.$addToSet || {}; updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] = (voiceDoc as any)._id; + updatePayload.$set[ + `expert.resend.parties.${resendIndex}.resendVoiceId` + ] = (voiceDoc as any)._id; + mergedRow.resendVoiceId = (voiceDoc as any)._id; uploadedItems.push(fieldName); } else if (fieldName === "video") { - // Create video record const videoDoc = await this.blameVideoDbService.create({ path: file.path, requestId: new Types.ObjectId(requestId), fileName: file.filename, }); - // Set video in party evidence updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] = String((videoDoc as any)._id); + updatePayload.$set[ + `expert.resend.parties.${resendIndex}.resendVideoId` + ] = (videoDoc as any)._id; + mergedRow.resendVideoId = (videoDoc as any)._id; uploadedItems.push(fieldName); } else { - // Document types (nationalCertificate, carCertificate, etc.) const docType = fieldName as BlameDocumentType; const doc = await this.blameDocumentDbService.create({ path: file.path, @@ -5794,22 +5914,16 @@ export class RequestManagementService { fileName: file.filename, documentType: docType, }); - // Store document reference (can be stored in a resend-specific field or added to party documents) - // For now, store in expert.resend.parties[].uploadedDocuments updatePayload.$set[ `expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}` ] = (doc as any)._id; + mergedRow.uploadedDocuments[fieldName] = (doc as any)._id; uploadedItems.push(fieldName); } } - if (uploadedItems.length === 0) { - throw new BadRequestException("No files were uploaded"); - } - - // Check if all requested items are now uploaded - const allItemsCompleted = requestedItems.every((item) => - uploadedItems.includes(item), + const allItemsCompleted = (requestedItems as string[]).every((item) => + isResendPartyItemSatisfied(item, mergedRow), ); if (allItemsCompleted) { @@ -5818,28 +5932,27 @@ export class RequestManagementService { new Date(); } - // Update the request await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); - // Check if all parties completed their resend const updatedRequest = await this.blameRequestDbService.findById(requestId); const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every( (p) => p.completed, ); if (allPartiesCompleted) { - // All parties completed - move back to WAITING_FOR_EXPERT await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { status: CaseStatus.WAITING_FOR_EXPERT, - "workflow.currentStep": "WAITING_FOR_GUILT_DECISION", + "workflow.currentStep": WorkflowStep.WAITING_FOR_GUILT_DECISION, "workflow.nextStep": null, }, $push: { - "workflow.completedSteps": "WAITING_FOR_DOCUMENT_RESEND", + "workflow.completedSteps": WorkflowStep.WAITING_FOR_DOCUMENT_RESEND, }, }); + await this.applyLinkedClaimsBlameResendCleared(requestId); + this.logger.log( `All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`, ); @@ -5923,20 +6036,51 @@ export class RequestManagementService { fileUrl: signFile.path, }; - // Update party confirmation - const updatePayload: any = { - $set: { - [`parties.${partyIndex}.confirmation`]: { - partyRole: userParty.role, - accepted: isAccept, - signature: signatureData, - }, - }, + const confirmationPayload = { + partyRole: userParty.role, + accepted: isAccept, + signature: signatureData, }; - await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); + // Any single rejection stops the case immediately (in-person resolution required). + if (!isAccept) { + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + status: CaseStatus.STOPPED, + [`parties.${partyIndex}.confirmation`]: confirmationPayload, + "workflow.nextStep": null, + }, + $push: { + history: { + type: "PARTY_REJECTED_EXPERT_DECISION", + timestamp: new Date(), + metadata: { + userId, + partyRole: userParty.role, + }, + }, + }, + }); + + this.logger.log( + `Party rejected expert decision for request ${requestId}; status STOPPED.`, + ); + + return { + requestId: String(request._id), + accepted: false, + status: CaseStatus.STOPPED, + message: + "You rejected the expert decision. This case is stopped and requires in-person resolution.", + }; + } + + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + [`parties.${partyIndex}.confirmation`]: confirmationPayload, + }, + }); - // Check if both parties have signed const updatedRequest = await this.blameRequestDbService.findById(requestId); const allPartiesSigned = updatedRequest.parties.every( (p) => p.confirmation !== undefined && p.confirmation !== null, @@ -5946,7 +6090,6 @@ export class RequestManagementService { let message = "Your signature has been recorded successfully"; if (allPartiesSigned) { - // Check if both accepted const allAccepted = updatedRequest.parties.every( (p) => p.confirmation?.accepted === true, ); @@ -5969,19 +6112,21 @@ export class RequestManagementService { }, }); } else { - // At least one party rejected - needs in-person resolution - finalStatus = CaseStatus.CANCELLED; // or a specific "NEEDS_IN_PERSON" status + finalStatus = CaseStatus.STOPPED; message = - "One or both parties rejected the decision. Case requires in-person resolution."; + "One party accepted and another rejected the decision. Case is stopped; in-person resolution required."; await this.blameRequestDbService.findByIdAndUpdate(requestId, { $set: { - status: CaseStatus.CANCELLED, - "workflow.currentStep": "COMPLETED", + status: CaseStatus.STOPPED, "workflow.nextStep": null, }, $push: { - "workflow.completedSteps": "WAITING_FOR_SIGNATURES", + history: { + type: "PARTIES_DISAGREED_ON_EXPERT_DECISION", + timestamp: new Date(), + metadata: {}, + }, }, }); } diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index 12f4831..93878e9 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -387,6 +387,11 @@ export class RequestManagementV2Controller { format: "binary", description: "Video file (if requested)", }, + description: { + type: "string", + description: + "Text description when expert requested ResendItemType.description", + }, }, }, }) @@ -427,11 +432,13 @@ export class RequestManagementV2Controller { voice?: Express.Multer.File[]; video?: Express.Multer.File[]; }, + @Body("description") description?: string, ) { return this.requestManagementService.uploadResendDocumentsV2( requestId, user.sub, files, + description, ); } } diff --git a/src/workflow-step-management/dto/workflow-step.examples.ts b/src/workflow-step-management/dto/workflow-step.examples.ts index 7c1d769..0cda519 100644 --- a/src/workflow-step-management/dto/workflow-step.examples.ts +++ b/src/workflow-step-management/dto/workflow-step.examples.ts @@ -1539,3 +1539,24 @@ export const claimWorkflowStepExamples = { } } }; + +/** + * Blame V2 expert resend: same `stepKey`s as the initial THIRD_PARTY blame flow + * (`FIRST_VOICE` → `FIRST_DESCRIPTION` → `SECOND_VOICE` → `SECOND_DESCRIPTION`). + * Seed workflow steps once via POST bodies below (or reuse existing rows). Resend only stores + * ordered keys on `blameCase.expert.resend` — clients resolve full field defs from `workflowSteps`. + */ +export const blameResendWorkflowStepExamples = { + firstVoice: createWorkflowStepExamples.example6, + firstDescription: createWorkflowStepExamples.example7, + secondVoice: createWorkflowStepExamples.example11, + secondDescription: createWorkflowStepExamples.example12, + stepKeysInOrder: [ + 'FIRST_VOICE', + 'FIRST_DESCRIPTION', + 'SECOND_VOICE', + 'SECOND_DESCRIPTION', + ] as const, + note: + 'Override per party with PartyResendRequestDto.requiredWorkflowStepKeys or ResendItemType.VOICE + DESCRIPTION when calling expert resend V2.', +} as const;