forked from Yara724/api
Compare commits
15 Commits
0086b8db4d
...
028ca899fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
028ca899fe | ||
| 2898897dd9 | |||
| e8a3873851 | |||
| 131df63a3f | |||
| b9eb0ab5bd | |||
| 4bdb9fd469 | |||
| 38f700c3d7 | |||
| a2f1d5ea7f | |||
| 12d6fa4d73 | |||
| 7661862564 | |||
|
|
494e3d93ab | ||
| 25958bb966 | |||
|
|
1d51370b3e | ||
| e44721b7be | |||
| 79c2982e19 |
@@ -1,19 +1,21 @@
|
|||||||
//! NEW
|
//! NEW
|
||||||
export enum CaseStatus {
|
export enum CaseStatus {
|
||||||
|
|
||||||
OPEN = "OPEN",
|
OPEN = "OPEN",
|
||||||
|
|
||||||
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
||||||
|
|
||||||
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
||||||
|
|
||||||
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
||||||
|
|
||||||
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
||||||
|
|
||||||
COMPLETED = "COMPLETED",
|
COMPLETED = "COMPLETED",
|
||||||
|
|
||||||
CANCELLED = "CANCELLED",
|
CANCELLED = "CANCELLED",
|
||||||
|
|
||||||
AUTO_CLOSED = "AUTO_CLOSED"
|
AUTO_CLOSED = "AUTO_CLOSED",
|
||||||
}
|
|
||||||
|
STOPPED = "STOPPED"
|
||||||
|
}
|
||||||
41
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
41
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -9,4 +9,7 @@ export enum ResendItemType {
|
|||||||
// Media evidence
|
// Media evidence
|
||||||
VOICE = "voice",
|
VOICE = "voice",
|
||||||
VIDEO = "video",
|
VIDEO = "video",
|
||||||
|
|
||||||
|
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||||
|
DESCRIPTION = "description",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export enum ClaimWorkflowStep {
|
|||||||
|
|
||||||
// Expert: Damage assessment
|
// Expert: Damage assessment
|
||||||
EXPERT_DAMAGE_ASSESSMENT = "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",
|
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||||
|
|
||||||
// Insurer approval
|
// Insurer approval
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { MyRequestsDto } from "./dto/my-request.dto";
|
|||||||
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
|
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
|
||||||
import { UserCommentDto } from "./dto/user-comment.dto";
|
import { UserCommentDto } from "./dto/user-comment.dto";
|
||||||
import { UserObjectionDto } from "./dto/user-objection.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 { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||||
import { ClaimCaseDbService } from "./entites/db-service/claim-case.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 { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-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 { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto";
|
||||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
@@ -2165,6 +2170,172 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase).
|
||||||
|
* Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`.
|
||||||
|
*/
|
||||||
|
async uploadClaimFactorV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
partId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
currentUserId: string,
|
||||||
|
actor?: { sub: string; role?: string },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("A factor file is required for upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||||
|
claim,
|
||||||
|
currentUserId,
|
||||||
|
actor?.role,
|
||||||
|
);
|
||||||
|
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
|
||||||
|
throw new ForbiddenException("You do not have access to this claim file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
||||||
|
? "damageExpertReplyFinal"
|
||||||
|
: "damageExpertReply";
|
||||||
|
const finalReply =
|
||||||
|
replyKey === "damageExpertReplyFinal"
|
||||||
|
? claim.evaluation?.damageExpertReplyFinal
|
||||||
|
: claim.evaluation?.damageExpertReply;
|
||||||
|
|
||||||
|
if (!finalReply?.parts?.length) {
|
||||||
|
throw new BadRequestException("No expert reply found in this claim.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const part = finalReply.parts.find((p) => String(p.partId) === String(partId));
|
||||||
|
if (!part) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The specified part was not found in the expert's reply.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part.factorNeeded) {
|
||||||
|
throw new BadRequestException("This part does not require a factor upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.factorLink) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"A factor has already been uploaded for this part.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partNameForRecord = part.carPartDamage ?? part.partId;
|
||||||
|
|
||||||
|
const factorRecord = await this.claimFactorsImageDbService.create({
|
||||||
|
claimId: new Types.ObjectId(claimRequestId),
|
||||||
|
partId: String(partId),
|
||||||
|
partName: partNameForRecord,
|
||||||
|
path: file.path,
|
||||||
|
fileName: file.filename,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathPrefix = `evaluation.${replyKey}.parts`;
|
||||||
|
|
||||||
|
const updatedClaim = await this.claimCaseDbService.findOneAndUpdate(
|
||||||
|
{
|
||||||
|
_id: new Types.ObjectId(claimRequestId),
|
||||||
|
[`${pathPrefix}.partId`]: partId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
[`${pathPrefix}.$.factorLink`]: factorRecord._id,
|
||||||
|
[`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedClaim) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Could not find the specific part to update in the database.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||||||
|
factorId: factorRecord._id,
|
||||||
|
url: buildFileLink(file.path),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error during V2 factor upload for claim ${claimRequestId}:`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
"An internal server error occurred during factor upload.",
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation).
|
||||||
|
*/
|
||||||
|
private async checkAndTransitionStatusAfterFactorUploadV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim?.evaluation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalReply =
|
||||||
|
claim.evaluation.damageExpertReplyFinal ||
|
||||||
|
claim.evaluation.damageExpertReply;
|
||||||
|
|
||||||
|
if (!finalReply?.parts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFactorParts = finalReply.parts.filter((p) => p.factorNeeded === true);
|
||||||
|
if (requiredFactorParts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFactorsAreUploaded = requiredFactorParts.every((p) => !!p.factorLink);
|
||||||
|
if (!allFactorsAreUploaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { claimRequestId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async checkAndTransitionStatusAfterUpload(
|
private async checkAndTransitionStatusAfterUpload(
|
||||||
claimId: string,
|
claimId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4252,6 +4423,288 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 API: Upload car walk-around video (same storage as v1: claim-video-capture + ClaimCase.media.videoCaptureId).
|
||||||
|
*/
|
||||||
|
async setVideoCaptureV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
fileDetail: Express.Multer.File,
|
||||||
|
currentUserId: string,
|
||||||
|
actor?: { sub: string; role?: string },
|
||||||
|
): Promise<VideoCaptureV2ResponseDto> {
|
||||||
|
try {
|
||||||
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
|
if (!claimCase) {
|
||||||
|
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||||
|
claimCase,
|
||||||
|
currentUserId,
|
||||||
|
actor?.role,
|
||||||
|
);
|
||||||
|
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||||
|
throw new ForbiddenException('Only the claim owner can upload car capture video');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileDetail) {
|
||||||
|
throw new BadRequestException('Video file is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCase.media?.videoCaptureId) {
|
||||||
|
throw new ConflictException('A video has already been uploaded for this claim.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoDoc = await this.createVideoCapture(fileDetail, claimRequestId);
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: { 'media.videoCaptureId': videoDoc._id },
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: 'VIDEO_CAPTURE_UPLOADED',
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || 'User',
|
||||||
|
actorType: 'user',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
videoId: videoDoc._id.toString(),
|
||||||
|
fileName: fileDetail.filename,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId: claimCase._id.toString(),
|
||||||
|
videoId: videoDoc._id.toString(),
|
||||||
|
message: 'Video capture uploaded successfully.',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error('setVideoCaptureV2 failed', error);
|
||||||
|
throw new InternalServerErrorException('Failed to upload video capture');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an expert resend request exists (user is expected to respond).
|
||||||
|
*/
|
||||||
|
private claimCaseHasExpertResend(claimCase: any): boolean {
|
||||||
|
const r = claimCase?.evaluation?.damageExpertResend;
|
||||||
|
if (!r) return false;
|
||||||
|
if (typeof r.resendDescription === 'string' && r.resendDescription.trim() !== '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
|
||||||
|
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 API: User objection (ClaimCase + evaluation.objection payload).
|
||||||
|
*
|
||||||
|
* Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet.
|
||||||
|
* Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts.
|
||||||
|
*/
|
||||||
|
async handleUserObjectionV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
body: UserObjectionV2Dto,
|
||||||
|
currentUserId: string,
|
||||||
|
actor?: { sub: string; role?: string },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
|
if (!claimCase) {
|
||||||
|
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||||
|
claimCase,
|
||||||
|
currentUserId,
|
||||||
|
actor?.role,
|
||||||
|
);
|
||||||
|
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||||
|
throw new ForbiddenException('Only the claim owner can submit an objection');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
|
||||||
|
const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply;
|
||||||
|
const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal;
|
||||||
|
|
||||||
|
if (!hasExpertResend && !hasFirstExpertReply) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You can only object after the damage expert has submitted an assessment or requested a resend.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFinalExpertReply) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCase.evaluation?.objection?.submittedAt) {
|
||||||
|
throw new ConflictException('An objection has already been submitted for this claim.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const partsIn = body.objectionParts ?? [];
|
||||||
|
const newPartsIn = body.newParts ?? [];
|
||||||
|
if (partsIn.length === 0 && newPartsIn.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Provide at least one entry in objectionParts and/or newParts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectionParts = partsIn.map((p) => ({
|
||||||
|
partId: p.partId,
|
||||||
|
reason: p.reason,
|
||||||
|
partPrice: p.partPrice,
|
||||||
|
partSalary: p.partSalary,
|
||||||
|
typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||||||
|
carPartDamage: p.carPartDamage,
|
||||||
|
side: p.side,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newParts = newPartsIn.map((p) => ({
|
||||||
|
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(),
|
||||||
|
partName: p.partName,
|
||||||
|
side: p.side,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedSelected = [...(claimCase.damage?.selectedParts ?? [])];
|
||||||
|
for (const np of newParts) {
|
||||||
|
const key = np.partName?.trim();
|
||||||
|
if (key && !mergedSelected.includes(key)) {
|
||||||
|
mergedSelected.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectionPayload = {
|
||||||
|
objectionParts,
|
||||||
|
newParts,
|
||||||
|
submittedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
|
claimStatus: ClaimStatus.PENDING,
|
||||||
|
'workflow.locked': false,
|
||||||
|
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
|
'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||||||
|
'evaluation.objection': objectionPayload,
|
||||||
|
'damage.selectedParts': mergedSelected,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: 'USER_OBJECTION_SUBMITTED',
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(effectiveUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || 'User',
|
||||||
|
actorType: 'user',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
objectionPartCount: objectionParts.length,
|
||||||
|
newPartCount: newParts.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return objectionPayload;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error('handleUserObjectionV2 failed', error);
|
||||||
|
throw new InternalServerErrorException('Failed to submit objection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 API: User satisfaction rating for a completed claim case (same intent as v1 PUT …/request/:id/user-rating).
|
||||||
|
*/
|
||||||
|
async addUserRatingV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
ratingDto: UserRatingDto,
|
||||||
|
currentUserId: string,
|
||||||
|
actor?: { sub: string; role?: string },
|
||||||
|
): Promise<UserClaimRating> {
|
||||||
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
|
if (!claimCase) {
|
||||||
|
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||||
|
claimCase,
|
||||||
|
currentUserId,
|
||||||
|
actor?.role,
|
||||||
|
);
|
||||||
|
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only the claim owner can submit a satisfaction rating.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'You can only rate a claim after it has been completed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimCase.userRating?.createdAt) {
|
||||||
|
throw new ConflictException('A rating has already been submitted for this claim.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = (n: number) => Number(n);
|
||||||
|
for (const [key, label] of [
|
||||||
|
[ratingDto.progressSpeed, 'progressSpeed'],
|
||||||
|
[ratingDto.registrationEase, 'registrationEase'],
|
||||||
|
[ratingDto.overallEvaluation, 'overallEvaluation'],
|
||||||
|
] as const) {
|
||||||
|
const v = score(key);
|
||||||
|
if (Number.isNaN(v) || v < 0 || v > 5) {
|
||||||
|
throw new BadRequestException(`${label} must be a number between 0 and 5.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRating: UserClaimRating = {
|
||||||
|
progressSpeed: ratingDto.progressSpeed,
|
||||||
|
registrationEase: ratingDto.registrationEase,
|
||||||
|
overallEvaluation: ratingDto.overallEvaluation,
|
||||||
|
comment: ratingDto.comment,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: { userRating },
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: 'USER_RATING_SUBMITTED',
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(effectiveUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || 'User',
|
||||||
|
actorType: 'user',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return userRating;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||||||
*/
|
*/
|
||||||
@@ -4398,6 +4851,15 @@ export class ClaimRequestManagementService {
|
|||||||
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
|
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
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,
|
createdAt: (claim as any).createdAt,
|
||||||
updatedAt: (claim as any).updatedAt,
|
updatedAt: (claim as any).updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Get,
|
Get,
|
||||||
@@ -25,9 +26,15 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
|
|||||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-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 { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-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)")
|
@ApiTags("claim-request-management (v2)")
|
||||||
@Controller("v2/claim-request-management")
|
@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")
|
@Post("create-from-blame/:blameRequestId")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "blameRequestId",
|
name: "blameRequestId",
|
||||||
@@ -614,4 +704,129 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
|
||||||
|
*/
|
||||||
|
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload factor file for a priced part (V2)",
|
||||||
|
description:
|
||||||
|
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
|
||||||
|
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
|
||||||
|
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
|
||||||
|
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||||
|
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-factors",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
callback(null, `-${unique}-${file.originalname}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async uploadFactorForPartV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Param("partId") partId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||||
|
claimRequestId,
|
||||||
|
partId,
|
||||||
|
file,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to upload factor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
|
||||||
|
*/
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/car-capture-videos/",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `claim-video-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@Patch("car-capture/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload car walk-around video (V2)",
|
||||||
|
description:
|
||||||
|
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
|
||||||
|
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
|
||||||
|
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Video uploaded successfully",
|
||||||
|
type: VideoCaptureV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||||
|
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||||
|
async captureVideoCaptureV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
): Promise<VideoCaptureV2ResponseDto> {
|
||||||
|
try {
|
||||||
|
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
|
claimRequestId,
|
||||||
|
file,
|
||||||
|
user.sub,
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,3 +79,26 @@ export class CapturePartV2ResponseDto {
|
|||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for car walk-around video upload (V2)
|
||||||
|
*/
|
||||||
|
export class VideoCaptureV2ResponseDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Claim case ID',
|
||||||
|
example: '507f1f77bcf86cd799439011',
|
||||||
|
})
|
||||||
|
claimRequestId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'ID of the stored video document (claim-video-capture)',
|
||||||
|
example: '507f1f77bcf86cd799439012',
|
||||||
|
})
|
||||||
|
videoId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Success message',
|
||||||
|
example: 'Video capture uploaded successfully.',
|
||||||
|
})
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||||
|
userRating?: {
|
||||||
|
progressSpeed: number;
|
||||||
|
registrationEase: number;
|
||||||
|
overallEvaluation: number;
|
||||||
|
comment?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiProperty({ description: 'Created at' })
|
@ApiProperty({ description: 'Created at' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
|||||||
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsArray, IsOptional, ValidateNested } from "class-validator";
|
||||||
|
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
|
||||||
|
* with nested validation enabled for the v2 controller pipeline.
|
||||||
|
*/
|
||||||
|
export class UserObjectionV2Dto {
|
||||||
|
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => UserObjectionPartDto)
|
||||||
|
objectionParts?: UserObjectionPartDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => NewPartDto)
|
||||||
|
newParts?: NewPartDto[];
|
||||||
|
}
|
||||||
@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
|
|||||||
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOneAndUpdate(
|
||||||
|
filter: FilterQuery<ClaimCase>,
|
||||||
|
update: any,
|
||||||
|
options?: { new?: boolean },
|
||||||
|
): Promise<ClaimCaseDocument | null> {
|
||||||
|
return this.claimCaseModel.findOneAndUpdate(filter, update, {
|
||||||
|
new: options?.new !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async find(
|
async find(
|
||||||
filter: FilterQuery<ClaimCase>,
|
filter: FilterQuery<ClaimCase>,
|
||||||
options?: { lean?: boolean; select?: string },
|
options?: { lean?: boolean; select?: string },
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ export class ClaimExpertReply {
|
|||||||
export const ClaimExpertReplySchema =
|
export const ClaimExpertReplySchema =
|
||||||
SchemaFactory.createForClass(ClaimExpertReply);
|
SchemaFactory.createForClass(ClaimExpertReply);
|
||||||
|
|
||||||
|
/** One line item the user disputes on an expert-priced part */
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimUserObjection {
|
export class ClaimUserObjectionPart {
|
||||||
@Prop({ type: String, required: true })
|
@Prop({ type: String, required: true })
|
||||||
partId: string;
|
partId: string;
|
||||||
|
|
||||||
@@ -103,9 +104,47 @@ export class ClaimUserObjection {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
typeOfDamage?: string;
|
typeOfDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
carPartDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
side?: string;
|
||||||
}
|
}
|
||||||
export const ClaimUserObjectionSchema =
|
export const ClaimUserObjectionPartSchema =
|
||||||
SchemaFactory.createForClass(ClaimUserObjection);
|
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 })
|
@Schema({ _id: false })
|
||||||
export class ClaimResendRequest {
|
export class ClaimResendRequest {
|
||||||
@@ -182,8 +221,8 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimResendRequestSchema })
|
@Prop({ type: ClaimResendRequestSchema })
|
||||||
damageExpertResend?: ClaimResendRequest | null;
|
damageExpertResend?: ClaimResendRequest | null;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserObjectionSchema })
|
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||||
objection?: ClaimUserObjection;
|
objection?: ClaimUserObjectionPayload;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||||
userResendDocuments?: ClaimUserResendPayload;
|
userResendDocuments?: ClaimUserResendPayload;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ClaimCaseSnapshotSchema,
|
ClaimCaseSnapshotSchema,
|
||||||
} from "./claim-case.snapshot.schema";
|
} from "./claim-case.snapshot.schema";
|
||||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||||
|
import { UserClaimRating } from "./claim-request-management.schema";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class RequiredDocumentRef {
|
export class RequiredDocumentRef {
|
||||||
@@ -75,6 +76,12 @@ export class ClaimCase {
|
|||||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||||
claimStatus: ClaimStatus;
|
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.
|
* Link to the blame case that originated this claim.
|
||||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||||
@@ -133,6 +140,12 @@ export class ClaimCase {
|
|||||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||||
history?: HistoryEvent[];
|
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 })
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
createdByRegistrarId?: Types.ObjectId;
|
createdByRegistrarId?: Types.ObjectId;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.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 { buildFileLink } from "src/helpers/urlCreator";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { ResendRequestDto } from "./dto/resend.dto";
|
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 { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.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 { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
|
||||||
|
import { RequestManagementService } from "src/request-management/request-management.service";
|
||||||
|
|
||||||
interface CheckedRequestEntry {
|
interface CheckedRequestEntry {
|
||||||
CheckedRequest?: {
|
CheckedRequest?: {
|
||||||
@@ -63,6 +65,10 @@ function statementToFormKey(
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertBlameService {
|
export class ExpertBlameService {
|
||||||
private readonly logger = new Logger(ExpertBlameService.name);
|
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(
|
constructor(
|
||||||
private readonly requestManagementDbService: RequestManagementDbService,
|
private readonly requestManagementDbService: RequestManagementDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
@@ -72,7 +78,8 @@ export class ExpertBlameService {
|
|||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
private readonly blameDocumentDbService: BlameDocumentDbService,
|
||||||
private readonly userSignDbService: UserSignDbService,
|
private readonly userSignDbService: UserSignDbService,
|
||||||
) {}
|
private readonly requestManagementService: RequestManagementService,
|
||||||
|
) { }
|
||||||
|
|
||||||
async findAll(actor: any): Promise<AllRequestDtoRs> {
|
async findAll(actor: any): Promise<AllRequestDtoRs> {
|
||||||
// 1. Fetch all potentially relevant requests from the database.
|
// 1. Fetch all potentially relevant requests from the database.
|
||||||
@@ -193,7 +200,13 @@ export class ExpertBlameService {
|
|||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const expertId = actor.sub;
|
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:
|
// Filter to show only:
|
||||||
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
|
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
|
||||||
@@ -239,6 +252,35 @@ export class ExpertBlameService {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const staleIds = new Set<string>();
|
||||||
|
for (const doc of visibleCases) {
|
||||||
|
const w = doc.workflow as Record<string, unknown> | 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<string, unknown>;
|
||||||
|
if (w) {
|
||||||
|
w.locked = false;
|
||||||
|
delete w.lockedAt;
|
||||||
|
delete w.lockedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const items: AllRequestDtoV2[] = visibleCases.map(
|
const items: AllRequestDtoV2[] = visibleCases.map(
|
||||||
(doc) => this.mapBlameRequestToListItemV2(doc),
|
(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<void> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
_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) {
|
public unlockApi(request, timer) {
|
||||||
return setTimeout(async () => {
|
return setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -410,9 +521,9 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
// 2. Initial validation to ensure the expert has access
|
// 2. Initial validation to ensure the expert has access
|
||||||
// Check if locked by current expert and lock is still active
|
// Check if locked by current expert and lock is still active
|
||||||
const isLockedByCurrentExpert =
|
const isLockedByCurrentExpert =
|
||||||
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
|
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
|
||||||
|
|
||||||
// Check if lock has expired
|
// Check if lock has expired
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
@@ -420,7 +531,7 @@ export class ExpertBlameService {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
isLockExpired = now >= unlockTime;
|
isLockExpired = now >= unlockTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLockedByCurrentExpert && !isLockExpired) {
|
if (isLockedByCurrentExpert && !isLockExpired) {
|
||||||
// This is the correct expert, and the file is locked to them, which is fine.
|
// 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.
|
// 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
|
// 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
|
// Only block if lock is still active and not by current expert
|
||||||
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
|
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 expertInitiated = doc.expertInitiated === true;
|
||||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||||
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
|
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 decision = (doc.expert as any)?.decision;
|
||||||
const decidedByExpertId = decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decidedByExpertId;
|
||||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||||
@@ -561,61 +669,78 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parties = (doc.parties ?? []) as Array<{
|
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
||||||
evidence?: { videoId?: string; voices?: string[] };
|
|
||||||
[key: string]: unknown;
|
const typedParties = parties as Array<{
|
||||||
|
vehicle?: {
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
evidence?: {
|
||||||
|
videoId?: string | number;
|
||||||
|
voices?: (string | number)[];
|
||||||
|
videoUrl?: string;
|
||||||
|
voiceUrls?: string[];
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
for (const party of parties) {
|
|
||||||
|
// Evidence (videos + voices)
|
||||||
|
for (const party of typedParties) {
|
||||||
if (!party.evidence) continue;
|
if (!party.evidence) continue;
|
||||||
const evidence = party.evidence as Record<string, unknown>;
|
const evidence = party.evidence as Record<string, unknown>;
|
||||||
|
|
||||||
if (evidence.videoId) {
|
if (evidence.videoId) {
|
||||||
const videoDoc = await this.blameVideoDbService.findById(
|
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId));
|
||||||
String(evidence.videoId),
|
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
||||||
);
|
|
||||||
if (videoDoc?.path) {
|
|
||||||
evidence.videoUrl = buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
if (evidence.voices && Array.isArray(evidence.voices)) {
|
||||||
const voiceUrls: string[] = [];
|
const voiceUrls: string[] = [];
|
||||||
for (const voiceId of evidence.voices) {
|
for (const voiceId of evidence.voices) {
|
||||||
const voiceDoc = await this.blameVoiceDbService.findById(
|
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId));
|
||||||
String(voiceId),
|
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
|
||||||
);
|
|
||||||
if (voiceDoc?.path) {
|
|
||||||
voiceUrls.push(buildFileLink(voiceDoc.path));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
evidence.voiceUrls = voiceUrls;
|
evidence.voiceUrls = voiceUrls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date();
|
// Date formatting
|
||||||
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date();
|
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 [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
||||||
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
||||||
|
|
||||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||||
|
|
||||||
// Exclude mapped inquiry vehicle for both parties from response
|
// Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields)
|
||||||
for (const party of parties) {
|
for (const party of typedParties) {
|
||||||
delete (party.vehicle as any).inquiry;
|
const veh = party?.vehicle as Record<string, unknown> | undefined;
|
||||||
|
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) {
|
||||||
|
delete veh.inquiry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"findOneV2 failed",
|
"findOneV2 failed",
|
||||||
requestId,
|
requestId,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error ? error.message : "Failed to get blame case details",
|
error instanceof Error ? error.message : "Failed to get blame case details",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async lockRequest(requestId: string, actorDetail) {
|
async lockRequest(requestId: string, actorDetail) {
|
||||||
const existing = await this.requestManagementDbService.findOne(requestId);
|
const existing = await this.requestManagementDbService.findOne(requestId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -691,8 +816,8 @@ export class ExpertBlameService {
|
|||||||
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
|
|
||||||
|
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
// First, check the current state
|
// First, check the current state
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -726,7 +851,8 @@ export class ExpertBlameService {
|
|||||||
if (request.workflow?.locked) {
|
if (request.workflow?.locked) {
|
||||||
const lockedAt = request.workflow.lockedAt;
|
const lockedAt = request.workflow.lockedAt;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
if (Date.now() < lockExpiryTime) {
|
if (Date.now() < lockExpiryTime) {
|
||||||
// Lock is still valid
|
// Lock is still valid
|
||||||
const lockedByActorId = String(
|
const lockedByActorId = String(
|
||||||
@@ -791,8 +917,19 @@ export class ExpertBlameService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
resendDto: ResendRequestDto,
|
resendDto: ResendRequestDto,
|
||||||
actor: any,
|
actor: any,
|
||||||
): Promise<{ requestId: string; status: string }> {
|
): Promise<{
|
||||||
|
requestId: string;
|
||||||
|
status: string;
|
||||||
|
parties: Array<{
|
||||||
|
partyId: string;
|
||||||
|
requestedItems: ResendItemType[];
|
||||||
|
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
@@ -820,7 +957,8 @@ export class ExpertBlameService {
|
|||||||
// Validate lock hasn't expired
|
// Validate lock hasn't expired
|
||||||
const lockedAt = request.workflow?.lockedAt;
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
if (Date.now() > lockExpiryTime) {
|
if (Date.now() > lockExpiryTime) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Your lock time has expired. Please lock the request again.",
|
"Your lock time has expired. Please lock the request again.",
|
||||||
@@ -842,16 +980,42 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedItems = new Set<string>(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();
|
const now = new Date();
|
||||||
|
|
||||||
// Build resend requests for parties
|
const partyResendRequests = resendDto.parties.map((party) => {
|
||||||
const partyResendRequests = resendDto.parties.map((party) => ({
|
const uid = String(party.partyId);
|
||||||
partyId: new Types.ObjectId(String(party.partyId)),
|
return {
|
||||||
requestedItems: party.requestedItems,
|
partyId: new Types.ObjectId(uid),
|
||||||
description: party.description || "",
|
requestedItems: party.requestedItems,
|
||||||
requestedAt: now,
|
description: party.description || "",
|
||||||
completed: false,
|
requestedAt: now,
|
||||||
}));
|
completed: false,
|
||||||
|
uploadedDocuments: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
$set: {
|
$set: {
|
||||||
@@ -865,9 +1029,6 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
||||||
},
|
},
|
||||||
$push: {
|
|
||||||
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
|
|
||||||
},
|
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
@@ -885,11 +1046,21 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||||
|
requestId,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Send notifications to parties (SMS/Push) about required documents
|
// TODO: Send notifications to parties (SMS/Push) about required documents
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -907,6 +1078,12 @@ export class ExpertBlameService {
|
|||||||
/**
|
/**
|
||||||
* V2: Submit expert reply for blame case (blameCases collection).
|
* V2: Submit expert reply for blame case (blameCases collection).
|
||||||
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
|
* 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(
|
async replyRequestV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -914,6 +1091,7 @@ export class ExpertBlameService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<{ requestId: string; status: string }> {
|
): Promise<{ requestId: string; status: string }> {
|
||||||
try {
|
try {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
@@ -945,7 +1123,8 @@ export class ExpertBlameService {
|
|||||||
// Check if lock has expired (15 minutes)
|
// Check if lock has expired (15 minutes)
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
isLockExpired = Date.now() > lockExpiryTime;
|
isLockExpired = Date.now() > lockExpiryTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1219,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(
|
private async updateDamageExpertStats(
|
||||||
expertId: string,
|
expertId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -1112,7 +1411,7 @@ export class ExpertBlameService {
|
|||||||
"Access denied to this request. You are not the locked expert.",
|
"Access denied to this request. You are not the locked expert.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired (unlockTime has passed)
|
// Check if lock has expired (unlockTime has passed)
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
@@ -1123,7 +1422,7 @@ export class ExpertBlameService {
|
|||||||
} else if (request.unlockTime == null) {
|
} else if (request.unlockTime == null) {
|
||||||
throw new ForbiddenException("Your lock time has expired.");
|
throw new ForbiddenException("Your lock time has expired.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.lockFile) {
|
if (!request.lockFile) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"You must lock the request before submitting a reply.",
|
"You must lock the request before submitting a reply.",
|
||||||
@@ -1374,4 +1673,5 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
|||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||||
export class ExpertBlameV2Controller {
|
export class ExpertBlameV2Controller {
|
||||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
constructor(private readonly expertBlameService: ExpertBlameService) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() actor: any) {
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||||
|
|
||||||
|
@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()
|
@ApiProperty()
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export class ClaimListItemV2Dto {
|
|||||||
|
|
||||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True when the claim is in the expert factor validation queue (all factors uploaded).',
|
||||||
|
})
|
||||||
|
awaitingFactorValidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetClaimListV2ResponseDto {
|
export class GetClaimListV2ResponseDto {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class CarPartDamageV2Dto {
|
||||||
@ApiProperty({ example: 'left' })
|
@ApiProperty({ example: 'left' })
|
||||||
@@ -68,3 +70,14 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
@Type(() => PartPricingV2Dto)
|
@Type(() => PartPricingV2Dto)
|
||||||
parts: 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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -31,3 +31,45 @@ export class FactorValidationDto {
|
|||||||
@Type(() => FactorDecisionDto)
|
@Type(() => FactorDecisionDto)
|
||||||
decisions: 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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -49,7 +50,9 @@ import { ClaimRequiredDocumentDbService } from "src/claim-request-management/ent
|
|||||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
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 { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
|
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertClaimService {
|
export class ExpertClaimService {
|
||||||
@@ -162,14 +165,14 @@ export class ExpertClaimService {
|
|||||||
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly sandHubService: SandHubService,
|
private readonly sandHubService: SandHubService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
private readonly clientDbService: ClientDbService,
|
private readonly clientDbService: ClientDbService,
|
||||||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
) { }
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
/** Map blame party `Vehicle` (name/model/type) to expert panel shape. */
|
||||||
private expertVehicleFromPartyVehicle(v: any): {
|
private expertVehicleFromPartyVehicle(v: any): {
|
||||||
@@ -604,7 +607,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
||||||
const requestId = request?._id?.toString() || 'unknown';
|
const requestId = request?._id?.toString() || 'unknown';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Check SandHub data
|
// Step 1: Check SandHub data
|
||||||
if (!request?.blameFile?.sanHubId) {
|
if (!request?.blameFile?.sanHubId) {
|
||||||
@@ -662,7 +665,7 @@ export class ExpertClaimService {
|
|||||||
} else if (severityValues.length > 0) {
|
} else if (severityValues.length > 0) {
|
||||||
// Only fetch car prices if we have severity values to calculate
|
// Only fetch car prices if we have severity values to calculate
|
||||||
const carPrices = await this.fetchCarPrices();
|
const carPrices = await this.fetchCarPrices();
|
||||||
|
|
||||||
if (!carPrices || carPrices.length === 0) {
|
if (!carPrices || carPrices.length === 0) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
|
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
|
||||||
@@ -672,9 +675,9 @@ export class ExpertClaimService {
|
|||||||
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const closestCar = this.findClosestCar(
|
const closestCar = this.findClosestCar(
|
||||||
request.carDetail.carName,
|
request.carDetail.carName,
|
||||||
carPrices,
|
carPrices,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!closestCar) {
|
if (!closestCar) {
|
||||||
@@ -688,11 +691,11 @@ export class ExpertClaimService {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
||||||
);
|
);
|
||||||
autoCalculatedData = {
|
autoCalculatedData = {
|
||||||
carPrice: closestCar.marketPrice,
|
carPrice: closestCar.marketPrice,
|
||||||
carValue: severityValues,
|
carValue: severityValues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,10 +733,10 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Save to database
|
// Step 6: Save to database
|
||||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||||
{ _id: new Types.ObjectId(request._id) },
|
{ _id: new Types.ObjectId(request._id) },
|
||||||
{ $set: { priceDrop: finalPriceDropData } },
|
{ $set: { priceDrop: finalPriceDropData } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
|
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
|
||||||
@@ -801,7 +804,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const severityValue =
|
const severityValue =
|
||||||
this.priceDropPart[originalPriceDropKey]?.[
|
this.priceDropPart[originalPriceDropKey]?.[
|
||||||
damagedPartInfo.severity
|
damagedPartInfo.severity
|
||||||
];
|
];
|
||||||
|
|
||||||
if (severityValue !== undefined) {
|
if (severityValue !== undefined) {
|
||||||
@@ -1098,13 +1101,13 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
||||||
// Check if locked by current user and lock is still active
|
// Check if locked by current user and lock is still active
|
||||||
const isLockedByCurrentUser =
|
const isLockedByCurrentUser =
|
||||||
String(request?.actorLocked?.actorId) === currentUser.sub &&
|
String(request?.actorLocked?.actorId) === currentUser.sub &&
|
||||||
request.lockFile;
|
request.lockFile;
|
||||||
|
|
||||||
if (!isLockedByCurrentUser) {
|
if (!isLockedByCurrentUser) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if lock has expired (unlockTime has passed)
|
// Also check if lock has expired (unlockTime has passed)
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
@@ -1116,7 +1119,7 @@ export class ExpertClaimService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,7 +1127,7 @@ export class ExpertClaimService {
|
|||||||
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
|
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired
|
// Check if lock has expired
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
@@ -1134,15 +1137,15 @@ export class ExpertClaimService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If currentUser is provided, allow access if they are the one who locked it
|
// If currentUser is provided, allow access if they are the one who locked it
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const isLockedByCurrentUser =
|
const isLockedByCurrentUser =
|
||||||
String(request?.actorLocked?.actorId) === currentUser.sub;
|
String(request?.actorLocked?.actorId) === currentUser.sub;
|
||||||
// Return false (not locked) if locked by current user, true if locked by someone else
|
// Return false (not locked) if locked by current user, true if locked by someone else
|
||||||
return !isLockedByCurrentUser;
|
return !isLockedByCurrentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no currentUser provided, treat as locked
|
// If no currentUser provided, treat as locked
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1165,18 +1168,18 @@ export class ExpertClaimService {
|
|||||||
) {
|
) {
|
||||||
throw new ForbiddenException("Access denied to this request");
|
throw new ForbiddenException("Access denied to this request");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired (unlockTime has passed)
|
// Check if lock has expired (unlockTime has passed)
|
||||||
if (request?.unlockTime && !request?.objection) {
|
if (request?.unlockTime && !request?.objection) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now >= unlockTime) {
|
if (now >= unlockTime) {
|
||||||
throw new ForbiddenException("Your time has expired");
|
throw new ForbiddenException("Your time has expired");
|
||||||
}
|
}
|
||||||
} else if (request?.unlockTime == null && !request?.objection) {
|
} else if (request?.unlockTime == null && !request?.objection) {
|
||||||
throw new ForbiddenException("Your time has expired");
|
throw new ForbiddenException("Your time has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.lockFile && !request?.objection) {
|
if (!request.lockFile && !request?.objection) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"For submit reply you must lock the request",
|
"For submit reply you must lock the request",
|
||||||
@@ -1385,6 +1388,66 @@ export class ExpertClaimService {
|
|||||||
return request;
|
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) {
|
async streamService(requestId, query, res, header) {
|
||||||
const request =
|
const request =
|
||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
@@ -1658,6 +1721,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<string, unknown> = {};
|
||||||
|
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) {
|
async inPersonVisit(requestId: string, actorDetail: any) {
|
||||||
const request =
|
const request =
|
||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
@@ -1768,6 +2035,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.
|
* V2: Submit damage expert reply for a claim.
|
||||||
*
|
*
|
||||||
@@ -1831,6 +2172,23 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
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 = {
|
const replyPayload = {
|
||||||
description: reply.description,
|
description: reply.description,
|
||||||
parts: reply.parts,
|
parts: reply.parts,
|
||||||
@@ -1850,7 +2208,7 @@ export class ExpertClaimService {
|
|||||||
? ClaimStatus.NEEDS_REVISION
|
? ClaimStatus.NEEDS_REVISION
|
||||||
: ClaimStatus.APPROVED;
|
: ClaimStatus.APPROVED;
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
const updatePayload: Record<string, unknown> = {
|
||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
'workflow.locked': false,
|
'workflow.locked': false,
|
||||||
@@ -1858,18 +2216,30 @@ export class ExpertClaimService {
|
|||||||
'workflow.nextStep': needsFactorUpload
|
'workflow.nextStep': needsFactorUpload
|
||||||
? ClaimWorkflowStep.INSURER_REVIEW
|
? ClaimWorkflowStep.INSURER_REVIEW
|
||||||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
'evaluation.damageExpertReply': replyPayload,
|
[`evaluation.${replyField}`]: replyPayload,
|
||||||
$push: {
|
$push: {
|
||||||
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
'workflow.completedSteps': completedStep,
|
||||||
history: {
|
history: {
|
||||||
event: 'EXPERT_REPLY_SUBMITTED',
|
type: isFinalReplyAfterObjection
|
||||||
performedBy: actor.sub,
|
? 'EXPERT_FINAL_REPLY_SUBMITTED'
|
||||||
performedByName: actor.fullName,
|
: 'EXPERT_REPLY_SUBMITTED',
|
||||||
performedAt: new Date(),
|
actor: {
|
||||||
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
|
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 {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -1877,6 +2247,7 @@ export class ExpertClaimService {
|
|||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
currentStep: nextStep,
|
currentStep: nextStep,
|
||||||
factorNeeded: needsFactorUpload,
|
factorNeeded: needsFactorUpload,
|
||||||
|
isFinalReplyAfterObjection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1949,10 +2320,10 @@ export class ExpertClaimService {
|
|||||||
/**
|
/**
|
||||||
* V2: Get claim list for damage expert
|
* V2: Get claim list for damage expert
|
||||||
*
|
*
|
||||||
* Returns claims that are:
|
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
|
||||||
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
|
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
|
||||||
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
|
* 2. Locked by this expert (in-progress)
|
||||||
* 3. Any status locked by THIS expert (their own in-progress work)
|
* 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
|
||||||
*/
|
*/
|
||||||
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
|
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
@@ -1970,6 +2341,12 @@ export class ExpertClaimService {
|
|||||||
'workflow.locked': true,
|
'workflow.locked': true,
|
||||||
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
|
'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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1996,6 +2373,10 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const list = filtered.map((c) => {
|
const list = filtered.map((c) => {
|
||||||
|
const awaitingFactorValidation =
|
||||||
|
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
|
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||||
|
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||||
const blame = c.blameRequestId
|
const blame = c.blameRequestId
|
||||||
? blameById.get(c.blameRequestId.toString())
|
? blameById.get(c.blameRequestId.toString())
|
||||||
@@ -2022,6 +2403,7 @@ export class ExpertClaimService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
...fileCtx,
|
...fileCtx,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
|
awaitingFactorValidation,
|
||||||
};
|
};
|
||||||
}) as ClaimListItemV2Dto[];
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
@@ -2032,15 +2414,14 @@ export class ExpertClaimService {
|
|||||||
* V2: Get claim detail for damage expert
|
* V2: Get claim detail for damage expert
|
||||||
*
|
*
|
||||||
* Validations:
|
* Validations:
|
||||||
* - Claim must exist
|
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||||
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
|
* - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert)
|
||||||
* - If claim is locked, only the locking expert can access it
|
* - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION
|
||||||
*/
|
*/
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
actor: any,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
requireActorClientKey(actor);
|
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
@@ -2050,14 +2431,22 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
assertClaimCaseForTenant(claim, actor);
|
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(
|
throw new ForbiddenException(
|
||||||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
`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 (
|
if (
|
||||||
|
isPickupReview &&
|
||||||
claim.workflow?.locked &&
|
claim.workflow?.locked &&
|
||||||
claim.workflow.lockedBy?.actorId?.toString() !== actorId
|
claim.workflow.lockedBy?.actorId?.toString() !== actorId
|
||||||
) {
|
) {
|
||||||
@@ -2146,16 +2535,16 @@ export class ExpertClaimService {
|
|||||||
locked: claim.workflow?.locked || false,
|
locked: claim.workflow?.locked || false,
|
||||||
lockedBy: claim.workflow?.lockedBy
|
lockedBy: claim.workflow?.lockedBy
|
||||||
? {
|
? {
|
||||||
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
||||||
actorName: claim.workflow.lockedBy.actorName,
|
actorName: claim.workflow.lockedBy.actorName,
|
||||||
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: claim.owner
|
owner: claim.owner
|
||||||
? {
|
? {
|
||||||
userId: claim.owner.userId?.toString(),
|
userId: claim.owner.userId?.toString(),
|
||||||
fullName: claim.owner.fullName,
|
fullName: claim.owner.fullName,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
vehicle: vehiclePayload,
|
vehicle: vehiclePayload,
|
||||||
...blameFileContext,
|
...blameFileContext,
|
||||||
@@ -2169,6 +2558,13 @@ export class ExpertClaimService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
|
evaluation: isFactorValidationPending
|
||||||
|
? {
|
||||||
|
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
|
||||||
|
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
createdAt: (claim as any).createdAt,
|
createdAt: (claim as any).createdAt,
|
||||||
updatedAt: (claim as any).updatedAt,
|
updatedAt: (claim as any).updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { IsOptional, IsString } from "class-validator";
|
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 { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
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 { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||||
|
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||||
|
|
||||||
class InPersonVisitV2Dto {
|
class InPersonVisitV2Dto {
|
||||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||||
@@ -30,13 +32,13 @@ class InPersonVisitV2Dto {
|
|||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||||
export class ExpertClaimV2Controller {
|
export class ExpertClaimV2Controller {
|
||||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
constructor(private readonly expertClaimService: ExpertClaimService) { }
|
||||||
|
|
||||||
@Get("requests")
|
@Get("requests")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available claim requests for damage expert",
|
summary: "List available claim requests for damage expert",
|
||||||
description:
|
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) {
|
async getClaimListV2(@CurrentUser() actor) {
|
||||||
return await this.expertClaimService.getClaimListV2(actor);
|
return await this.expertClaimService.getClaimListV2(actor);
|
||||||
@@ -46,7 +48,7 @@ export class ExpertClaimV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get claim request detail for damage expert",
|
summary: "Get claim request detail for damage expert",
|
||||||
description:
|
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" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
@@ -86,6 +88,22 @@ export class ExpertClaimV2Controller {
|
|||||||
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
|
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")
|
@Patch(":claimRequestId/visit")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Request in-person visit for claim",
|
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")
|
@Patch("request/:claimRequestId/damaged-parts")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Edit selected damaged parts (V2)",
|
summary: "Edit selected damaged parts (V2)",
|
||||||
@@ -125,4 +163,21 @@ export class ExpertClaimV2Controller {
|
|||||||
actor,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,401 +1,45 @@
|
|||||||
import {
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
BadGatewayException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import axios from "axios";
|
|
||||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LookupsService {
|
export class LookupsService {
|
||||||
private readonly logger = new Logger(LookupsService.name);
|
private readonly logger = new Logger(LookupsService.name);
|
||||||
private readonly GET_APP_TOKEN_URL =
|
|
||||||
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
|
|
||||||
private readonly LOGIN_URL =
|
|
||||||
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
|
|
||||||
private readonly BASE_API_URL =
|
|
||||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
|
|
||||||
private readonly BASE_INFO_PATH = "base-info";
|
|
||||||
private readonly CODE_LIST_PATH = "code-list";
|
|
||||||
|
|
||||||
// Authentication credentials
|
constructor(private readonly lookupDbService: LookupDbService) {}
|
||||||
private readonly APP_NAME = "fanhab";
|
|
||||||
private readonly SECRET = "5Fa@N#A2B";
|
|
||||||
private readonly USERNAME = "fanhabUser";
|
|
||||||
private readonly PASSWORD = "Fan#@2U$3er";
|
|
||||||
|
|
||||||
// API headers
|
|
||||||
private readonly CORP_ID = "3539";
|
|
||||||
private readonly CONTRACT_ID = "263";
|
|
||||||
private readonly LOCATION = "100";
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly lookupDbService: LookupDbService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1: Get appToken from GetAppToken API
|
* Returns stored `response` for a lookup document by `name` in the lookups collection.
|
||||||
* The token is returned in the response header 'appToken'
|
|
||||||
*/
|
*/
|
||||||
private async getAppToken(): Promise<string> {
|
async getLookup(lookupName: string): Promise<any> {
|
||||||
try {
|
const doc = await this.lookupDbService.findOne({ name: lookupName });
|
||||||
const requestHeaders: any = {
|
if (!doc) {
|
||||||
appname: this.APP_NAME,
|
this.logger.warn(`Lookup not found in database: ${lookupName}`);
|
||||||
secret: this.SECRET,
|
throw new NotFoundException(`Lookup "${lookupName}" not found`);
|
||||||
"Content-Length": "0",
|
|
||||||
};
|
|
||||||
// Remove Content-Type header when Content-Length is 0
|
|
||||||
delete requestHeaders["Content-Type"];
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: "POST",
|
|
||||||
url: this.GET_APP_TOKEN_URL,
|
|
||||||
data: "", // Empty string body
|
|
||||||
headers: requestHeaders,
|
|
||||||
transformRequest: [
|
|
||||||
(data, headers) => {
|
|
||||||
// Remove Content-Type header completely for empty body
|
|
||||||
if (headers) {
|
|
||||||
delete headers["Content-Type"];
|
|
||||||
delete headers["content-type"];
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Axios lowercases headers, so check both cases
|
|
||||||
const appToken =
|
|
||||||
response.headers.apptoken ||
|
|
||||||
response.headers.appToken ||
|
|
||||||
response.headers["apptoken"] ||
|
|
||||||
response.headers["appToken"];
|
|
||||||
if (!appToken) {
|
|
||||||
this.logger.error("Failed to get appToken from response headers");
|
|
||||||
this.logger.error("Response status:", response.status);
|
|
||||||
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
|
|
||||||
this.logger.error("Available header keys:", Object.keys(response.headers));
|
|
||||||
this.logger.error("Response data:", response.data);
|
|
||||||
|
|
||||||
// Extract error message from response if available
|
|
||||||
const errorMessage = response.data?.Message ||
|
|
||||||
response.data?.message ||
|
|
||||||
response.data ||
|
|
||||||
"Failed to get appToken from response headers";
|
|
||||||
|
|
||||||
throw new BadGatewayException(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Successfully obtained appToken: ${appToken}`);
|
|
||||||
return appToken;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Failed to get appToken");
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
this.logger.error("Error status:", error.response?.status);
|
|
||||||
this.logger.error("Error status text:", error.response?.statusText);
|
|
||||||
this.logger.error("Error response data:", error.response?.data);
|
|
||||||
this.logger.error("Error response headers:", error.response?.headers);
|
|
||||||
this.logger.error("Request URL:", error.config?.url);
|
|
||||||
this.logger.error("Request headers:", error.config?.headers);
|
|
||||||
|
|
||||||
// Extract error message from response
|
|
||||||
const errorMessage = error.response?.data?.Message ||
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data ||
|
|
||||||
error.message ||
|
|
||||||
"Failed to get appToken from external API";
|
|
||||||
|
|
||||||
throw new BadGatewayException(errorMessage);
|
|
||||||
} else {
|
|
||||||
this.logger.error("Error details:", error);
|
|
||||||
throw new BadGatewayException("Failed to get appToken from external API");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return doc.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2: Login to get authenticationToken
|
|
||||||
* The token is returned in the response body field 'authenticationtoken'
|
|
||||||
*/
|
|
||||||
private async login(appToken: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Attempting login with appToken: ${appToken}`);
|
|
||||||
this.logger.log(`Login URL: ${this.LOGIN_URL}`);
|
|
||||||
this.logger.log(`Username: ${this.USERNAME}`);
|
|
||||||
|
|
||||||
const requestHeaders: any = {
|
|
||||||
appToken: appToken,
|
|
||||||
userName: this.USERNAME,
|
|
||||||
password: this.PASSWORD,
|
|
||||||
"Content-Length": "0",
|
|
||||||
};
|
|
||||||
// Remove Content-Type header when Content-Length is 0
|
|
||||||
// Axios adds it automatically, but server doesn't expect it for empty body
|
|
||||||
delete requestHeaders["Content-Type"];
|
|
||||||
|
|
||||||
this.logger.log("Request headers (without password):", {
|
|
||||||
appToken: appToken,
|
|
||||||
userName: this.USERNAME,
|
|
||||||
password: "***",
|
|
||||||
"Content-Length": "0",
|
|
||||||
"Content-Type": "removed",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: "POST",
|
|
||||||
url: this.LOGIN_URL,
|
|
||||||
data: "", // Empty string body
|
|
||||||
headers: requestHeaders,
|
|
||||||
transformRequest: [
|
|
||||||
(data, headers) => {
|
|
||||||
// Remove Content-Type header completely for empty body
|
|
||||||
if (headers) {
|
|
||||||
delete headers["Content-Type"];
|
|
||||||
delete headers["content-type"];
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log("Login response status:", response.status);
|
|
||||||
this.logger.log("Login response data:", JSON.stringify(response.data, null, 2));
|
|
||||||
this.logger.log("Login response headers:", JSON.stringify(response.headers, null, 2));
|
|
||||||
|
|
||||||
// Check response headers first (authenticationToken is in headers, not body)
|
|
||||||
const authenticationToken =
|
|
||||||
response.headers.authenticationtoken ||
|
|
||||||
response.headers.authenticationToken ||
|
|
||||||
response.headers["authenticationtoken"] ||
|
|
||||||
response.headers["authenticationToken"] ||
|
|
||||||
// Fallback to body if not in headers
|
|
||||||
response.data?.authenticationtoken ||
|
|
||||||
response.data?.authenticationToken ||
|
|
||||||
response.data?.authentication_token;
|
|
||||||
|
|
||||||
this.logger.log(`Extracted authenticationToken: ${authenticationToken ? 'Found' : 'Not found'}`);
|
|
||||||
|
|
||||||
if (!authenticationToken) {
|
|
||||||
this.logger.error("Failed to get authenticationToken from response");
|
|
||||||
this.logger.error("Response status:", response.status);
|
|
||||||
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
|
|
||||||
this.logger.error("Response headers keys:", Object.keys(response.headers || {}));
|
|
||||||
this.logger.error("Response data:", JSON.stringify(response.data, null, 2));
|
|
||||||
this.logger.error("Response data keys:", Object.keys(response.data || {}));
|
|
||||||
|
|
||||||
// Extract error message from response if available
|
|
||||||
const errorMessage = response.data?.Message ||
|
|
||||||
response.data?.message ||
|
|
||||||
"Failed to get authenticationToken from response";
|
|
||||||
|
|
||||||
throw new BadGatewayException(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Successfully obtained authenticationToken: ${authenticationToken}`);
|
|
||||||
return authenticationToken;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Failed to login");
|
|
||||||
this.logger.error("AppToken used:", appToken);
|
|
||||||
this.logger.error("Login URL:", this.LOGIN_URL);
|
|
||||||
this.logger.error("Username:", this.USERNAME);
|
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
this.logger.error("Error status:", error.response?.status);
|
|
||||||
this.logger.error("Error status text:", error.response?.statusText);
|
|
||||||
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
|
|
||||||
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
|
|
||||||
this.logger.error("Request URL:", error.config?.url);
|
|
||||||
this.logger.error("Request method:", error.config?.method);
|
|
||||||
this.logger.error("Request headers:", JSON.stringify({
|
|
||||||
...error.config?.headers,
|
|
||||||
password: "***",
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
// Extract error message from response
|
|
||||||
const errorMessage = error.response?.data?.Message ||
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data ||
|
|
||||||
error.message ||
|
|
||||||
"Failed to login to external API";
|
|
||||||
|
|
||||||
throw new BadGatewayException(errorMessage);
|
|
||||||
} else {
|
|
||||||
this.logger.error("Error details:", error);
|
|
||||||
throw new BadGatewayException("Failed to login to external API");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 3: Call the lookup API with authenticationToken
|
|
||||||
*/
|
|
||||||
private async callLookupApi(
|
|
||||||
lookupName: string,
|
|
||||||
authenticationToken: string,
|
|
||||||
pathType: "base-info" | "code-list" = "base-info",
|
|
||||||
): Promise<any> {
|
|
||||||
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
|
|
||||||
try {
|
|
||||||
const response = await axios.get(url, {
|
|
||||||
headers: {
|
|
||||||
authenticationToken: authenticationToken,
|
|
||||||
CorpId: this.CORP_ID,
|
|
||||||
ContractId: this.CONTRACT_ID,
|
|
||||||
Location: this.LOCATION,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Successfully fetched data for lookup: ${lookupName}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to fetch lookup data for ${lookupName}`);
|
|
||||||
this.logger.error("URL:", url);
|
|
||||||
this.logger.error("AuthenticationToken used:", authenticationToken);
|
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
this.logger.error("Error status:", error.response?.status);
|
|
||||||
this.logger.error("Error status text:", error.response?.statusText);
|
|
||||||
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
|
|
||||||
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
|
|
||||||
this.logger.error("Request URL:", error.config?.url);
|
|
||||||
this.logger.error("Request headers:", JSON.stringify(error.config?.headers, null, 2));
|
|
||||||
// Extract error message from response
|
|
||||||
const errorMessage = error.response?.data?.Message ||
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.response?.data ||
|
|
||||||
error.message ||
|
|
||||||
`Failed to fetch lookup data for ${lookupName}`;
|
|
||||||
|
|
||||||
throw new BadGatewayException(errorMessage);
|
|
||||||
} else {
|
|
||||||
this.logger.error("Error details:", error);
|
|
||||||
throw new BadGatewayException(
|
|
||||||
`Failed to fetch lookup data for ${lookupName}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete authentication flow and fetch lookup data
|
|
||||||
*/
|
|
||||||
private async fetchLookupDataFromExternalApi(
|
|
||||||
lookupName: string,
|
|
||||||
pathType: "base-info" | "code-list" = "base-info",
|
|
||||||
): Promise<{ authenticationToken: string; response: any; url: string }> {
|
|
||||||
// Step 1: Get appToken
|
|
||||||
const appToken = await this.getAppToken();
|
|
||||||
|
|
||||||
// Step 2: Login to get authenticationToken
|
|
||||||
const authenticationToken = await this.login(appToken);
|
|
||||||
|
|
||||||
// Step 3: Call the lookup API
|
|
||||||
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
|
|
||||||
const response = await this.callLookupApi(
|
|
||||||
lookupName,
|
|
||||||
authenticationToken,
|
|
||||||
pathType,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { authenticationToken, response, url };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if lookup data needs to be updated (once a month)
|
|
||||||
*/
|
|
||||||
private shouldUpdateLookup(updatedAt: Date | null): boolean {
|
|
||||||
if (!updatedAt) {
|
|
||||||
return true; // Never updated, need to fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
const oneMonthAgo = new Date();
|
|
||||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
||||||
|
|
||||||
return updatedAt < oneMonthAgo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get lookup data by name
|
|
||||||
* Returns cached data if available and not expired, otherwise fetches from external API
|
|
||||||
*/
|
|
||||||
async getLookup(
|
|
||||||
lookupName: string,
|
|
||||||
pathType: "base-info" | "code-list" = "base-info",
|
|
||||||
): Promise<any> {
|
|
||||||
// Check if we have cached data
|
|
||||||
const existingLookup = await this.lookupDbService.findOne({
|
|
||||||
name: lookupName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingLookup && !this.shouldUpdateLookup(existingLookup.updatedAt)) {
|
|
||||||
this.logger.log(
|
|
||||||
`Returning cached data for lookup: ${lookupName} (last updated: ${existingLookup.updatedAt})`,
|
|
||||||
);
|
|
||||||
return existingLookup.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to fetch from external API
|
|
||||||
this.logger.log(`Fetching fresh data for lookup: ${lookupName}`);
|
|
||||||
const { authenticationToken, response, url } =
|
|
||||||
await this.fetchLookupDataFromExternalApi(lookupName, pathType);
|
|
||||||
|
|
||||||
// Save or update in database
|
|
||||||
await this.lookupDbService.findOneAndUpdate(
|
|
||||||
{ name: lookupName },
|
|
||||||
{
|
|
||||||
url: url,
|
|
||||||
authenticationToken: authenticationToken,
|
|
||||||
name: lookupName,
|
|
||||||
response: response,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully saved/updated lookup data for: ${lookupName}`);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accident-causes lookup data
|
|
||||||
*/
|
|
||||||
async getAccidentCauses(): Promise<any> {
|
async getAccidentCauses(): Promise<any> {
|
||||||
return await this.getLookup("accident-causes", "base-info");
|
return await this.getLookup("accident-causes");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accident-report-type lookup data
|
|
||||||
*/
|
|
||||||
async getAccidentReportType(): Promise<any> {
|
async getAccidentReportType(): Promise<any> {
|
||||||
return await this.getLookup("accident-report-type", "code-list");
|
return await this.getLookup("accident-report-type");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get vehicle-use-types lookup data
|
|
||||||
*/
|
|
||||||
async getVehicleUseTypes(): Promise<any> {
|
async getVehicleUseTypes(): Promise<any> {
|
||||||
return await this.getLookup("vehicle-use-types", "base-info");
|
return await this.getLookup("vehicle-use-types");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dmg-pay-method lookup data
|
|
||||||
*/
|
|
||||||
async getDmgPayMethod(): Promise<any> {
|
async getDmgPayMethod(): Promise<any> {
|
||||||
return await this.getLookup("dmg-pay-method", "code-list");
|
return await this.getLookup("dmg-pay-method");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get driving-licence-types lookup data
|
|
||||||
*/
|
|
||||||
async getDrivingLicenceTypes(): Promise<any> {
|
async getDrivingLicenceTypes(): Promise<any> {
|
||||||
return await this.getLookup("driving-licence-types", "base-info");
|
return await this.getLookup("driving-licence-types");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accident-culprit-type lookup data
|
|
||||||
*/
|
|
||||||
async getAccidentCulpritType(): Promise<any> {
|
async getAccidentCulpritType(): Promise<any> {
|
||||||
return await this.getLookup("accident-culprit-type", "code-list");
|
return await this.getLookup("accident-culprit-type");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class UserResendResponseDto {
|
export class UserResendResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
|
|||||||
description: "Whether the user has completed uploading all requested items",
|
description: "Whether the user has completed uploading all requested items",
|
||||||
})
|
})
|
||||||
completed: boolean;
|
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 {
|
export class UserResendUploadResponseDto {
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export class PartyResendRequest {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
requestedItems: string[]; // Array of ResendItemType values
|
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()
|
@Prop()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@@ -75,6 +79,20 @@ export class PartyResendRequest {
|
|||||||
|
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
|
|
||||||
|
/** Document resend uploads keyed by ResendItemType (e.g. drivingLicense). */
|
||||||
|
@Prop({ type: Object, default: {} })
|
||||||
|
uploadedDocuments?: Record<string, Types.ObjectId>;
|
||||||
|
|
||||||
|
@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);
|
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
|
||||||
|
|
||||||
@@ -89,6 +107,12 @@ export class ExpertResend {
|
|||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
requestedByExpertId?: 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);
|
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ import { RequestManagementDbService } from "src/request-management/entities/db-s
|
|||||||
import { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto";
|
import { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto";
|
||||||
import { UserSignatureResponseDto } from "./dto/user-signature.dto";
|
import { UserSignatureResponseDto } from "./dto/user-signature.dto";
|
||||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
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 { SandHubService } from "src/sand-hub/sand-hub.service";
|
||||||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||||||
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
||||||
@@ -46,7 +53,6 @@ import { RequestManagementModel } from "./entities/schema/request-management.sch
|
|||||||
import { BlameDocumentType } from "./entities/schema/blame-document.schema";
|
import { BlameDocumentType } from "./entities/schema/blame-document.schema";
|
||||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
|
||||||
import {
|
import {
|
||||||
CreationMethod,
|
CreationMethod,
|
||||||
FilledBy,
|
FilledBy,
|
||||||
@@ -308,6 +314,64 @@ export class RequestManagementService {
|
|||||||
private readonly userAuthService: UserAuthService,
|
private readonly userAuthService: UserAuthService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked claim cases: block user on damage flow while blame awaits document resend.
|
||||||
|
*/
|
||||||
|
async applyLinkedClaimsBlameResendStarted(blameRequestId: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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) {
|
async createRequestV2(user: any, type: BlameRequestType) {
|
||||||
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
|
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
|
||||||
if (!firstStep?.stepKey) {
|
if (!firstStep?.stepKey) {
|
||||||
@@ -5761,11 +5825,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 {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
requestedItems: userResendRequest.requestedItems,
|
requestedItems: userResendRequest.requestedItems,
|
||||||
description: userResendRequest.description || "",
|
description: userResendRequest.description || "",
|
||||||
completed: userResendRequest.completed || false,
|
completed: userResendRequest.completed || false,
|
||||||
|
requestedItemsUi: buildResendItemsWithUi(items),
|
||||||
|
remainingItems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5783,6 +5863,7 @@ export class RequestManagementService {
|
|||||||
voice?: Express.Multer.File[];
|
voice?: Express.Multer.File[];
|
||||||
video?: Express.Multer.File[];
|
video?: Express.Multer.File[];
|
||||||
},
|
},
|
||||||
|
textDescription?: string,
|
||||||
): Promise<UserResendUploadResponseDto> {
|
): Promise<UserResendUploadResponseDto> {
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -5806,7 +5887,9 @@ export class RequestManagementService {
|
|||||||
throw new ForbiddenException("You are not a party in this request");
|
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) {
|
if (partyIndex === -1) {
|
||||||
throw new ForbiddenException("Party not found in request");
|
throw new ForbiddenException("Party not found in request");
|
||||||
}
|
}
|
||||||
@@ -5826,50 +5909,85 @@ export class RequestManagementService {
|
|||||||
const userResendRequest = resendParties[resendIndex];
|
const userResendRequest = resendParties[resendIndex];
|
||||||
const requestedItems = userResendRequest.requestedItems || [];
|
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 uploadedItems: string[] = [];
|
||||||
const updatePayload: any = { $set: {} };
|
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) {
|
for (const fieldName in files) {
|
||||||
const fileArray = files[fieldName];
|
const fileArray = files[fieldName as keyof typeof files];
|
||||||
if (!fileArray || fileArray.length === 0) continue;
|
if (!fileArray || fileArray.length === 0) continue;
|
||||||
|
|
||||||
const file = fileArray[0];
|
const file = fileArray[0];
|
||||||
|
|
||||||
// Check if this item was requested
|
|
||||||
if (!requestedItems.includes(fieldName)) {
|
if (!requestedItems.includes(fieldName)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`,
|
`${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different file types
|
|
||||||
if (fieldName === "voice") {
|
if (fieldName === "voice") {
|
||||||
// Create voice record
|
|
||||||
const voiceDoc = await this.blameVoiceDbService.create({
|
const voiceDoc = await this.blameVoiceDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
requestId: new Types.ObjectId(requestId),
|
requestId: new Types.ObjectId(requestId),
|
||||||
fileName: file.filename,
|
fileName: file.filename,
|
||||||
context: "EXPERT_RESEND" as any,
|
context: "EXPERT_RESEND" as any,
|
||||||
});
|
});
|
||||||
// Add voice to party evidence
|
|
||||||
updatePayload.$addToSet = updatePayload.$addToSet || {};
|
updatePayload.$addToSet = updatePayload.$addToSet || {};
|
||||||
updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] =
|
updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] =
|
||||||
(voiceDoc as any)._id;
|
(voiceDoc as any)._id;
|
||||||
|
updatePayload.$set[
|
||||||
|
`expert.resend.parties.${resendIndex}.resendVoiceId`
|
||||||
|
] = (voiceDoc as any)._id;
|
||||||
|
mergedRow.resendVoiceId = (voiceDoc as any)._id;
|
||||||
uploadedItems.push(fieldName);
|
uploadedItems.push(fieldName);
|
||||||
} else if (fieldName === "video") {
|
} else if (fieldName === "video") {
|
||||||
// Create video record
|
|
||||||
const videoDoc = await this.blameVideoDbService.create({
|
const videoDoc = await this.blameVideoDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
requestId: new Types.ObjectId(requestId),
|
requestId: new Types.ObjectId(requestId),
|
||||||
fileName: file.filename,
|
fileName: file.filename,
|
||||||
});
|
});
|
||||||
// Set video in party evidence
|
|
||||||
updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] =
|
updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] =
|
||||||
String((videoDoc as any)._id);
|
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);
|
uploadedItems.push(fieldName);
|
||||||
} else {
|
} else {
|
||||||
// Document types (nationalCertificate, carCertificate, etc.)
|
|
||||||
const docType = fieldName as BlameDocumentType;
|
const docType = fieldName as BlameDocumentType;
|
||||||
const doc = await this.blameDocumentDbService.create({
|
const doc = await this.blameDocumentDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
@@ -5877,22 +5995,16 @@ export class RequestManagementService {
|
|||||||
fileName: file.filename,
|
fileName: file.filename,
|
||||||
documentType: docType,
|
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[
|
updatePayload.$set[
|
||||||
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
|
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
|
||||||
] = (doc as any)._id;
|
] = (doc as any)._id;
|
||||||
|
mergedRow.uploadedDocuments[fieldName] = (doc as any)._id;
|
||||||
uploadedItems.push(fieldName);
|
uploadedItems.push(fieldName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadedItems.length === 0) {
|
const allItemsCompleted = (requestedItems as string[]).every((item) =>
|
||||||
throw new BadRequestException("No files were uploaded");
|
isResendPartyItemSatisfied(item, mergedRow),
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all requested items are now uploaded
|
|
||||||
const allItemsCompleted = requestedItems.every((item) =>
|
|
||||||
uploadedItems.includes(item),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allItemsCompleted) {
|
if (allItemsCompleted) {
|
||||||
@@ -5901,28 +6013,27 @@ export class RequestManagementService {
|
|||||||
new Date();
|
new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the request
|
|
||||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||||||
|
|
||||||
// Check if all parties completed their resend
|
|
||||||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||||||
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
|
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
|
||||||
(p) => p.completed,
|
(p) => p.completed,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allPartiesCompleted) {
|
if (allPartiesCompleted) {
|
||||||
// All parties completed - move back to WAITING_FOR_EXPERT
|
|
||||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||||||
$set: {
|
$set: {
|
||||||
status: CaseStatus.WAITING_FOR_EXPERT,
|
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||||
"workflow.currentStep": "WAITING_FOR_GUILT_DECISION",
|
"workflow.currentStep": WorkflowStep.WAITING_FOR_GUILT_DECISION,
|
||||||
"workflow.nextStep": null,
|
"workflow.nextStep": null,
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
"workflow.completedSteps": "WAITING_FOR_DOCUMENT_RESEND",
|
"workflow.completedSteps": WorkflowStep.WAITING_FOR_DOCUMENT_RESEND,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.applyLinkedClaimsBlameResendCleared(requestId);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`,
|
`All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`,
|
||||||
);
|
);
|
||||||
@@ -6006,20 +6117,51 @@ export class RequestManagementService {
|
|||||||
fileUrl: signFile.path,
|
fileUrl: signFile.path,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update party confirmation
|
const confirmationPayload = {
|
||||||
const updatePayload: any = {
|
partyRole: userParty.role,
|
||||||
$set: {
|
accepted: isAccept,
|
||||||
[`parties.${partyIndex}.confirmation`]: {
|
signature: signatureData,
|
||||||
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 updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||||||
const allPartiesSigned = updatedRequest.parties.every(
|
const allPartiesSigned = updatedRequest.parties.every(
|
||||||
(p) => p.confirmation !== undefined && p.confirmation !== null,
|
(p) => p.confirmation !== undefined && p.confirmation !== null,
|
||||||
@@ -6029,7 +6171,6 @@ export class RequestManagementService {
|
|||||||
let message = "Your signature has been recorded successfully";
|
let message = "Your signature has been recorded successfully";
|
||||||
|
|
||||||
if (allPartiesSigned) {
|
if (allPartiesSigned) {
|
||||||
// Check if both accepted
|
|
||||||
const allAccepted = updatedRequest.parties.every(
|
const allAccepted = updatedRequest.parties.every(
|
||||||
(p) => p.confirmation?.accepted === true,
|
(p) => p.confirmation?.accepted === true,
|
||||||
);
|
);
|
||||||
@@ -6052,19 +6193,21 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// At least one party rejected - needs in-person resolution
|
finalStatus = CaseStatus.STOPPED;
|
||||||
finalStatus = CaseStatus.CANCELLED; // or a specific "NEEDS_IN_PERSON" status
|
|
||||||
message =
|
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, {
|
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||||||
$set: {
|
$set: {
|
||||||
status: CaseStatus.CANCELLED,
|
status: CaseStatus.STOPPED,
|
||||||
"workflow.currentStep": "COMPLETED",
|
|
||||||
"workflow.nextStep": null,
|
"workflow.nextStep": null,
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
|
history: {
|
||||||
|
type: "PARTIES_DISAGREED_ON_EXPERT_DECISION",
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ export class RequestManagementV2Controller {
|
|||||||
format: "binary",
|
format: "binary",
|
||||||
description: "Video file (if requested)",
|
description: "Video file (if requested)",
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Text description when expert requested ResendItemType.description",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -433,11 +438,13 @@ export class RequestManagementV2Controller {
|
|||||||
voice?: Express.Multer.File[];
|
voice?: Express.Multer.File[];
|
||||||
video?: Express.Multer.File[];
|
video?: Express.Multer.File[];
|
||||||
},
|
},
|
||||||
|
@Body("description") description?: string,
|
||||||
) {
|
) {
|
||||||
return this.requestManagementService.uploadResendDocumentsV2(
|
return this.requestManagementService.uploadResendDocumentsV2(
|
||||||
requestId,
|
requestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
files,
|
files,
|
||||||
|
description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user