forked from Yara724/api
5621 lines
196 KiB
TypeScript
5621 lines
196 KiB
TypeScript
import {
|
||
BadGatewayException,
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
HttpException,
|
||
HttpStatus,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
ConflictException,
|
||
InternalServerErrorException,
|
||
} from "@nestjs/common";
|
||
import { Types } from "mongoose";
|
||
import { v4 as uuidv4 } from "uuid";
|
||
import axios from "axios";
|
||
import { unlink } from "node:fs/promises";
|
||
import { existsSync } from "node:fs";
|
||
import { AiService } from "src/ai/ai.service";
|
||
import { buildFileLink } from "src/helpers/urlCreator";
|
||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
||
import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.enum";
|
||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||
import { CarDamagePartDto } from "./dto/car-part.dto";
|
||
import { ClaimPartUploadDetail } from "./dto/claim-detail";
|
||
import { CreateClaimRequestDtoRs } from "./dto/create-claim.dto";
|
||
import { ClaimRequestDtoRs } from "./dto/claim-rs-dto";
|
||
import { ImageRequiredDto } from "./dto/image-required.dto";
|
||
import { MyRequestsDto } from "./dto/my-request.dto";
|
||
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
|
||
import { UserCommentDto } from "./dto/user-comment.dto";
|
||
import { UserObjectionDto } from "./dto/user-objection.dto";
|
||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
|
||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
|
||
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
|
||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||
import {
|
||
CapturePartV2Dto,
|
||
CapturePartV2ResponseDto,
|
||
VideoCaptureV2ResponseDto,
|
||
} from "./dto/capture-part-v2.dto";
|
||
import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto";
|
||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||
import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
||
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
||
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
||
import { VideoCaptureDbService } from "./entites/db-service/video-capture.db.service";
|
||
import { ClaimRequiredDocumentDbService } from "./entites/db-service/claim-required-document.db.service";
|
||
import {
|
||
ClaimRequestManagementModel,
|
||
UserClaimRating,
|
||
} from "./entites/schema/claim-request-management.schema";
|
||
import { PublicIdService } from "src/utils/public-id/public-id.service";
|
||
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
|
||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||
import {
|
||
ExpertFileActivityType,
|
||
ExpertFileKind,
|
||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||
import {
|
||
canFinalizeExpertResend,
|
||
documentKeyAllowedForExpertResend,
|
||
normalizeResendDocumentKeys,
|
||
normalizeResendPartKeys,
|
||
partKeyAllowedForExpertResend,
|
||
} from "src/helpers/claim-expert-resend";
|
||
import {
|
||
canonicalizeClaimCarAngleKey,
|
||
getClaimCarAngleCaptureBlob,
|
||
hasClaimCarAngleCapture,
|
||
legacyAngleDamagedPartFieldKeysToUnset,
|
||
type ClaimCarAngleKey,
|
||
} from "src/helpers/claim-car-angle-media";
|
||
import {
|
||
ClaimVehicleTypeV2,
|
||
OUTER_PARTS_BY_CAR_TYPE,
|
||
OuterPartCatalogItem,
|
||
} from "src/static/outer-car-parts-catalog";
|
||
|
||
@Injectable()
|
||
export class ClaimRequestManagementService {
|
||
private readonly logger = new Logger(ClaimRequestManagementService.name);
|
||
|
||
// Authentication URLs and credentials (same as lookups service)
|
||
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 FANAVARAN_SUBMIT_URL =
|
||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car/third-party-car-financial-claims";
|
||
|
||
// Authentication credentials
|
||
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 blameDbService: RequestManagementDbService,
|
||
private readonly claimDbService: ClaimRequestManagementDbService,
|
||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||
private readonly blameRequestDbService: BlameRequestDbService,
|
||
private readonly carGreenCardDbService: CarGreenCardDbService,
|
||
private readonly damageImageDbService: DamageImageDbService,
|
||
private readonly userDbService: UserDbService,
|
||
private readonly videoCaptureDbService: VideoCaptureDbService,
|
||
private readonly claimSignDbService: ClaimSignDbService,
|
||
private readonly aiService: AiService,
|
||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||
private readonly damageExpertDbService: DamageExpertDbService,
|
||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||
private readonly branchDbService: BranchDbService,
|
||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||
private readonly publicIdService: PublicIdService,
|
||
) {}
|
||
|
||
private delay(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] {
|
||
if (carType) {
|
||
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
|
||
...p,
|
||
carType,
|
||
}));
|
||
}
|
||
const out: OuterPartCatalogItem[] = [];
|
||
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
|
||
const t = type as ClaimVehicleTypeV2;
|
||
for (const p of items) {
|
||
out.push({ ...p, carType: t });
|
||
}
|
||
}
|
||
return out.sort((a, b) => {
|
||
if (a.carType === b.carType) return a.id - b.id;
|
||
return String(a.carType).localeCompare(String(b.carType));
|
||
});
|
||
}
|
||
|
||
private userDamageDetail(blRequest: RequestManagementModel) {
|
||
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
|
||
switch (blRequest.expertSubmitReply.guiltyUserId) {
|
||
case first.firstPartyId:
|
||
return { isGuilty: first, isNotGuilty: second };
|
||
case second.secondPartyId:
|
||
return { isGuilty: second, isNotGuilty: first };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper method to get the effective user ID for claim operations.
|
||
* For experts accessing IN_PERSON claim files, returns the claim file's userId.
|
||
* For regular users, returns their own ID.
|
||
*/
|
||
private async getEffectiveUserId(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
): Promise<string> {
|
||
// If actor is a user, return their ID
|
||
if (actor.role === RoleEnum.USER) {
|
||
return actor.sub;
|
||
}
|
||
|
||
// If actor is an expert, verify they're accessing an IN_PERSON claim file
|
||
// and return the claim file's userId
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (expertRoles.includes(actor.role)) {
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
const blameFile = claim.blameFile;
|
||
if (
|
||
blameFile?.expertInitiated &&
|
||
blameFile?.creationMethod === "IN_PERSON" &&
|
||
String(blameFile.initiatedBy) === actor.sub
|
||
) {
|
||
// Expert is accessing IN_PERSON file they initiated - use claim's userId
|
||
return String(claim.userId);
|
||
}
|
||
|
||
throw new ForbiddenException(
|
||
"Experts can only access IN_PERSON claim files they initiated",
|
||
);
|
||
}
|
||
|
||
throw new ForbiddenException("Invalid role");
|
||
}
|
||
|
||
async createClaimRequest(
|
||
requestId: string,
|
||
CurrentUserId?: string,
|
||
actor?: any,
|
||
) {
|
||
try {
|
||
const blameRequest = await this.blameDbService.findOne(requestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Source blame request not found.");
|
||
}
|
||
|
||
// Ensure the blame request has a shared human-friendly id.
|
||
// For legacy records, generate it lazily exactly once.
|
||
let publicId = (blameRequest as any).publicId as string | undefined;
|
||
if (!publicId) {
|
||
publicId = await this.publicIdService.generateRequestPublicId();
|
||
await this.blameDbService.findAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{ $set: { publicId } },
|
||
);
|
||
}
|
||
|
||
const existingClaimCount = await this.claimDbService.countByFilter({
|
||
"blameFile._id": new Types.ObjectId(requestId),
|
||
blameRequestId: new Types.ObjectId(requestId),
|
||
publicId,
|
||
});
|
||
if (existingClaimCount > 0) {
|
||
throw new ForbiddenException(
|
||
"A claim request for this blame file already exists.",
|
||
);
|
||
}
|
||
|
||
const isStatusValid =
|
||
blameRequest.blameStatus === ReqBlameStatus.CloseRequest;
|
||
|
||
if (!isStatusValid) {
|
||
throw new HttpException(
|
||
`Blame request must be 'CloseRequest', but is currently '${blameRequest.blameStatus}'.`,
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
if (!blameRequest.expertSubmitReply) {
|
||
throw new HttpException(
|
||
"Cannot create a claim file until the expert has submitted a reply.",
|
||
HttpStatus.GONE,
|
||
);
|
||
}
|
||
|
||
// Determine the damaged user (non-guilty party)
|
||
const guiltyUserId = String(blameRequest.expertSubmitReply.guiltyUserId);
|
||
let damagedUserId: string;
|
||
|
||
// For IN_PERSON expert-initiated files, expert creates the claim
|
||
// For LINK method or regular files, user creates their own claim
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (
|
||
blameRequest.expertInitiated &&
|
||
blameRequest.creationMethod === "IN_PERSON" &&
|
||
actor &&
|
||
expertRoles.includes(actor.role)
|
||
) {
|
||
// Expert is creating claim for IN_PERSON file
|
||
// Verify expert is the initiating expert
|
||
if (String(blameRequest.initiatedBy) !== actor.sub) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating expert can create claim files for IN_PERSON files.",
|
||
);
|
||
}
|
||
|
||
// Determine damaged user (the one who is NOT guilty)
|
||
if (
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === guiltyUserId
|
||
) {
|
||
damagedUserId = String(
|
||
blameRequest.secondPartyDetails.secondPartyId,
|
||
);
|
||
} else {
|
||
damagedUserId = String(blameRequest.firstPartyDetails.firstPartyId);
|
||
}
|
||
} else {
|
||
// Regular user flow
|
||
if (!CurrentUserId) {
|
||
throw new BadRequestException("User ID is required.");
|
||
}
|
||
if (guiltyUserId === CurrentUserId) {
|
||
throw new HttpException(
|
||
"You cannot submit a claim file as you are the guilty party.",
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
damagedUserId = CurrentUserId;
|
||
}
|
||
|
||
const userDetail = await this.userDbService.findOne({
|
||
_id: new Types.ObjectId(damagedUserId),
|
||
});
|
||
if (!userDetail) {
|
||
throw new NotFoundException("User details could not be found.");
|
||
}
|
||
|
||
const carDetail =
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
|
||
? blameRequest.firstPartyDetails.firstPartyCarDetail
|
||
: blameRequest.secondPartyDetails.secondPartyCarDetail;
|
||
|
||
const carPlate =
|
||
String(blameRequest.firstPartyDetails.firstPartyId) === damagedUserId
|
||
? blameRequest.firstPartyDetails.firstPartyPlate
|
||
: blameRequest.secondPartyDetails.secondPartyPlate;
|
||
|
||
const newClaimPayload = {
|
||
blameFile: blameRequest,
|
||
userId: new Types.ObjectId(damagedUserId),
|
||
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
|
||
steps: [ClaimStepsEnum.CreateClaimFile],
|
||
currentStep: ClaimStepsEnum.CreateClaimFile,
|
||
nextStep: ClaimStepsEnum.SelectDamagePart,
|
||
fullName: userDetail.fullName,
|
||
carDetail,
|
||
carPlate,
|
||
userClientKey: new Types.ObjectId(userDetail.clientKey),
|
||
createdAt: new Date(),
|
||
};
|
||
|
||
const response = await this.claimDbService.create(newClaimPayload as any);
|
||
|
||
return new CreateClaimRequestDtoRs(
|
||
response["_id"],
|
||
"Claim file created successfully.",
|
||
);
|
||
} catch (er) {
|
||
this.logger.error(er);
|
||
|
||
if (er instanceof HttpException) {
|
||
throw er;
|
||
}
|
||
|
||
if (er.code === 11000) {
|
||
throw new HttpException(
|
||
"Duplicate claim file reference.",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
private findTrueItems(userPartSelect: CarDamagePartDto) {
|
||
const trueItems = [];
|
||
for (const side in userPartSelect) {
|
||
for (const parts in userPartSelect[side]) {
|
||
if (userPartSelect[side][parts] === true) {
|
||
trueItems.push({ side, part: parts });
|
||
}
|
||
}
|
||
}
|
||
return trueItems;
|
||
}
|
||
|
||
/** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */
|
||
private carPartDamageSectionToRows(section: unknown): Record<string, unknown>[] {
|
||
if (section == null) return [];
|
||
if (Array.isArray(section)) {
|
||
return section.filter(
|
||
(row): row is Record<string, unknown> =>
|
||
!!row && typeof row === "object",
|
||
);
|
||
}
|
||
if (typeof section === "object") {
|
||
return [section as Record<string, unknown>];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
private carPartDamageSectionHasTrue(section: unknown): boolean {
|
||
for (const row of this.carPartDamageSectionToRows(section)) {
|
||
for (const val of Object.values(row)) {
|
||
if (val === true) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* At most two of left, right, front, back may contain any damaged part (true).
|
||
* `top` and claim `otherParts` are not subject to this rule.
|
||
*/
|
||
private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void {
|
||
const d = dto as unknown as Record<string, unknown>;
|
||
const quadrants = [
|
||
{ key: "left", section: d.left },
|
||
{ key: "right", section: d.right },
|
||
{ key: "front", section: d.front },
|
||
{ key: "back", section: d.back },
|
||
];
|
||
const used = quadrants.filter((q) =>
|
||
this.carPartDamageSectionHasTrue(q.section),
|
||
);
|
||
if (used.length > 2) {
|
||
throw new BadRequestException(
|
||
`At most two of the four sides (left, right, front, back) may include damaged parts. ` +
|
||
`Sides with selections: ${used.map((u) => u.key).join(", ")}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts).
|
||
*/
|
||
private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] {
|
||
const out = new Set<string>();
|
||
const add = (slug: string | undefined) => {
|
||
if (slug) out.add(slug);
|
||
};
|
||
const walkMainOnSide = (
|
||
side: "left" | "right",
|
||
items: unknown,
|
||
map: Record<string, string>,
|
||
) => {
|
||
for (const row of this.carPartDamageSectionToRows(items)) {
|
||
for (const [key, val] of Object.entries(row)) {
|
||
if (val !== true) continue;
|
||
const slug = map[`${side}_${key}`] ?? map[key];
|
||
add(slug);
|
||
}
|
||
}
|
||
};
|
||
// MainParts on left / right (side-specific door / fender)
|
||
walkMainOnSide("left", (dto as any).left, {
|
||
left_frontDoor: "front_left_door",
|
||
left_backDoor: "rear_left_door",
|
||
left_frontFender: "front_left_fender",
|
||
left_backFender: "rear_left_fender",
|
||
frontDoor: "front_left_door",
|
||
backDoor: "rear_left_door",
|
||
frontFender: "front_left_fender",
|
||
backFender: "rear_left_fender",
|
||
});
|
||
walkMainOnSide("right", (dto as any).right, {
|
||
right_frontDoor: "front_right_door",
|
||
right_backDoor: "rear_right_door",
|
||
right_frontFender: "front_right_fender",
|
||
right_backFender: "rear_right_fender",
|
||
frontDoor: "front_right_door",
|
||
backDoor: "rear_right_door",
|
||
frontFender: "front_right_fender",
|
||
backFender: "rear_right_fender",
|
||
});
|
||
const walkSimple = (items: unknown, keyToSlug: Record<string, string>) => {
|
||
for (const row of this.carPartDamageSectionToRows(items)) {
|
||
for (const [key, val] of Object.entries(row)) {
|
||
if (val === true) add(keyToSlug[key]);
|
||
}
|
||
}
|
||
};
|
||
walkSimple((dto as any).front, {
|
||
frontBumper: "front_bumper",
|
||
carHood: "hood",
|
||
});
|
||
walkSimple((dto as any).back, {
|
||
backBumper: "rear_bumper",
|
||
carTrunk: "trunk",
|
||
});
|
||
walkSimple((dto as any).top, {
|
||
roof: "roof",
|
||
});
|
||
return Array.from(out);
|
||
}
|
||
|
||
/** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */
|
||
private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] {
|
||
if (!raw || !Array.isArray(raw)) return [];
|
||
const out: string[] = [];
|
||
for (const item of raw) {
|
||
if (typeof item === "string" && item.trim()) {
|
||
out.push(item.trim());
|
||
continue;
|
||
}
|
||
if (item && typeof item === "object") {
|
||
for (const [k, v] of Object.entries(item)) {
|
||
if (v === true && k) out.push(k);
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private async assertFieldExpertInPersonBlameForClaimCase(
|
||
claimCase: any,
|
||
expert: any,
|
||
): Promise<void> {
|
||
const blameRequestId = claimCase?.blameRequestId?.toString?.();
|
||
if (!blameRequestId) {
|
||
throw new BadRequestException("Claim case has no linked blame request.");
|
||
}
|
||
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found for this claim.");
|
||
}
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
blameRequest.creationMethod !== CreationMethod.IN_PERSON
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON claim files.",
|
||
);
|
||
}
|
||
if (!blameRequest.initiatedByFieldExpertId) {
|
||
throw new BadRequestException(
|
||
"Blame request is missing initiating field expert.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating field expert can complete claim data for this file.",
|
||
);
|
||
}
|
||
}
|
||
|
||
async setCarPartDamageAndImage(cl, trueItems) {
|
||
cl.carPartDamage = trueItems;
|
||
const jsonTrueItems = JSON.parse(JSON.stringify(trueItems));
|
||
cl.imageRequired = new ImageRequiredModel(jsonTrueItems);
|
||
cl.save();
|
||
return cl.imageRequired;
|
||
}
|
||
|
||
async selectCarPartDamage(
|
||
requestId: string,
|
||
userPartSelect: CarDamagePartDto,
|
||
actor: any,
|
||
): Promise<any> {
|
||
try {
|
||
const cl = await this.claimDbService.findOne(requestId);
|
||
|
||
if (!cl) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(cl.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
if (cl.carPartDamage) {
|
||
throw new HttpException(
|
||
"car damage part has exist",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
const trueItems = this.findTrueItems(userPartSelect);
|
||
const damageParts = this.setCarPartDamageAndImage(cl, trueItems);
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
currentStep: ClaimStepsEnum.SelectDamagePart,
|
||
nextStep: ClaimStepsEnum.SelectOtherParts,
|
||
$push: {
|
||
steps: ClaimStepsEnum.SelectDamagePart,
|
||
},
|
||
});
|
||
|
||
return damageParts;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Field expert completes claim-needed data for expert-initiated IN_PERSON files.
|
||
* Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts.
|
||
*
|
||
* V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported.
|
||
*/
|
||
async expertCompleteClaimData(
|
||
claimRequestId: string,
|
||
expert: any,
|
||
dto: {
|
||
carPartDamage?: CarDamagePartDto;
|
||
sheba?: string;
|
||
nationalCodeOfInsurer?: string;
|
||
otherParts?: any[];
|
||
},
|
||
): Promise<{ success: boolean; claimRequestId: string }> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (claimCase) {
|
||
return this.expertCompleteClaimDataForClaimCaseV2(
|
||
claimRequestId,
|
||
expert,
|
||
dto,
|
||
claimCase,
|
||
);
|
||
}
|
||
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
const blameFile = claim.blameFile as any;
|
||
if (!blameFile?.expertInitiated || blameFile?.creationMethod !== "IN_PERSON") {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON claim files.",
|
||
);
|
||
}
|
||
if (String(blameFile.initiatedBy) !== expert.sub) {
|
||
throw new ForbiddenException(
|
||
"Only the initiating field expert can complete claim data for this file.",
|
||
);
|
||
}
|
||
|
||
if (dto.carPartDamage) {
|
||
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
||
throw new BadRequestException("Car part damage is already set.");
|
||
}
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||
const trueItems = this.findTrueItems(dto.carPartDamage);
|
||
const claimDoc = (await this.claimDbService.findOne(
|
||
claimRequestId,
|
||
)) as any;
|
||
if (claimDoc) {
|
||
await this.setCarPartDamageAndImage(claimDoc, trueItems);
|
||
}
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
currentStep: ClaimStepsEnum.SelectDamagePart,
|
||
nextStep: ClaimStepsEnum.SelectOtherParts,
|
||
$push: { steps: ClaimStepsEnum.SelectDamagePart },
|
||
});
|
||
}
|
||
|
||
const updates: Record<string, any> = {};
|
||
if (dto.sheba != null) updates.sheba = dto.sheba;
|
||
if (dto.nationalCodeOfInsurer != null)
|
||
updates.nationalCodeOfInsurer = dto.nationalCodeOfInsurer;
|
||
if (dto.otherParts != null) updates.otherParts = dto.otherParts;
|
||
if (Object.keys(updates).length > 0) {
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
$set: updates,
|
||
});
|
||
}
|
||
|
||
return { success: true, claimRequestId };
|
||
}
|
||
|
||
/**
|
||
* V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only).
|
||
*/
|
||
private async expertCompleteClaimDataForClaimCaseV2(
|
||
claimRequestId: string,
|
||
expert: any,
|
||
dto: {
|
||
carPartDamage?: CarDamagePartDto;
|
||
sheba?: string;
|
||
nationalCodeOfInsurer?: string;
|
||
otherParts?: any[];
|
||
},
|
||
claimCase: any,
|
||
): Promise<{ success: boolean; claimRequestId: string }> {
|
||
await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert);
|
||
|
||
let working = claimCase;
|
||
|
||
if (dto.carPartDamage) {
|
||
if (working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) {
|
||
throw new BadRequestException(
|
||
`Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (
|
||
working.damage?.selectedParts &&
|
||
working.damage.selectedParts.length > 0
|
||
) {
|
||
throw new BadRequestException("Car part damage is already set.");
|
||
}
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||
const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage);
|
||
if (!selectedParts.length) {
|
||
throw new BadRequestException(
|
||
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
||
);
|
||
}
|
||
|
||
const updated = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
"damage.selectedParts": selectedParts,
|
||
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName:
|
||
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||
"field_expert",
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
selectedParts,
|
||
description: "Field expert submitted outer damage parts",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
);
|
||
if (!updated) {
|
||
throw new InternalServerErrorException("Failed to update claim case");
|
||
}
|
||
working = updated;
|
||
}
|
||
|
||
const hasBankOrOther =
|
||
dto.sheba != null ||
|
||
dto.nationalCodeOfInsurer != null ||
|
||
dto.otherParts != null;
|
||
if (hasBankOrOther) {
|
||
const fresh = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!fresh) throw new NotFoundException("Claim case not found");
|
||
if (fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) {
|
||
throw new BadRequestException(
|
||
`Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
|
||
const mergedSheba =
|
||
dto.sheba != null ? dto.sheba : fresh.money?.sheba;
|
||
const mergedNational =
|
||
dto.nationalCodeOfInsurer != null
|
||
? dto.nationalCodeOfInsurer
|
||
: fresh.money?.nationalCodeOfInsurer;
|
||
|
||
if (fresh.money?.sheba && dto.sheba != null && dto.sheba !== fresh.money.sheba) {
|
||
throw new ConflictException("Sheba is already set for this claim.");
|
||
}
|
||
if (
|
||
fresh.money?.nationalCodeOfInsurer &&
|
||
dto.nationalCodeOfInsurer != null &&
|
||
dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer
|
||
) {
|
||
throw new ConflictException(
|
||
"National code of insurer is already set for this claim.",
|
||
);
|
||
}
|
||
|
||
const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts);
|
||
const setPatch: Record<string, unknown> = {};
|
||
if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba;
|
||
if (dto.nationalCodeOfInsurer != null) {
|
||
setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer;
|
||
}
|
||
if (dto.otherParts != null) {
|
||
setPatch["damage.otherParts"] =
|
||
otherPartsArr.length > 0 ? otherPartsArr : [];
|
||
}
|
||
|
||
const bankComplete =
|
||
mergedSheba &&
|
||
mergedNational &&
|
||
/^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) &&
|
||
/^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, ""));
|
||
|
||
if (bankComplete) {
|
||
const shebaNorm = String(mergedSheba).replace(/\s/g, "");
|
||
const nationalNorm = String(mergedNational).replace(/\s/g, "");
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
"money.sheba": shebaNorm,
|
||
"money.nationalCodeOfInsurer": nationalNorm,
|
||
...(dto.otherParts != null
|
||
? {
|
||
"damage.otherParts":
|
||
otherPartsArr.length > 0 ? otherPartsArr : [],
|
||
}
|
||
: {}),
|
||
status: ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||
"workflow.currentStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
history: {
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName:
|
||
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||
"field_expert",
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
description:
|
||
"Field expert submitted bank info and optional other parts",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
} else {
|
||
if (Object.keys(setPatch).length === 0) {
|
||
throw new BadRequestException(
|
||
"Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.",
|
||
);
|
||
}
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: setPatch,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { success: true, claimRequestId };
|
||
}
|
||
|
||
async selectCarOtherPartDamage(
|
||
requestId: string,
|
||
body: any, // Use your DTO for better type safety
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
// 1. Fetch the claim request and perform initial validation
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(claimRequest.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
if (
|
||
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
|
||
effectiveUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"User access denied. You are the guilty party in the blame file.",
|
||
);
|
||
}
|
||
|
||
if (!claimRequest.carPlate) {
|
||
throw new BadRequestException(
|
||
"Cannot validate ownership: Plate information is missing from the claim file.",
|
||
);
|
||
}
|
||
|
||
// await this.sandHubService.getCarOwnershipInfo(
|
||
// claimRequest.carPlate,
|
||
// body.nationalCodeOfInsurer,
|
||
// );
|
||
|
||
// await this.sandHubService.getShebaValidation(
|
||
// body.nationalCodeOfInsurer,
|
||
// body.sheba,
|
||
// );
|
||
|
||
const greenCard = await this.carGreenCardDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: requestId,
|
||
});
|
||
if (!greenCard) {
|
||
throw new BadGatewayException(
|
||
"Failed to save car green card document.",
|
||
);
|
||
}
|
||
|
||
const updatePayload = {
|
||
$set: {
|
||
otherParts: JSON.parse(body.otherParts as any),
|
||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||
sheba: body.sheba,
|
||
carGreenCard: {
|
||
path: buildFileLink(greenCard.path),
|
||
fileName: greenCard.fileName,
|
||
claimId: requestId,
|
||
},
|
||
currentStep: ClaimStepsEnum.SelectOtherParts,
|
||
nextStep: ClaimStepsEnum.ImageRequired,
|
||
claimStatus: ReqClaimStatus.WaitingForUserCompleted,
|
||
},
|
||
$push: {
|
||
steps: ClaimStepsEnum.SelectOtherParts,
|
||
},
|
||
};
|
||
|
||
this.logger.log(
|
||
`[CLAIM] Moving to ImageRequired step for claim ${requestId}. Status set to: ${ReqClaimStatus.WaitingForUserCompleted}`,
|
||
);
|
||
|
||
const updatedClaim = await this.claimDbService.findByIdAndUpdate(
|
||
requestId,
|
||
updatePayload,
|
||
);
|
||
|
||
// 6. Return the successful response
|
||
return new ClaimPartUploadDetail([updatedClaim]);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in selectCarOtherPartDamage: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
// Re-throw the specific error from the validation steps or a generic one.
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the list of required document types based on claim type and whether there's a second party car
|
||
* For CAR_BODY type with accident with another object (not a car), only require 7 damaged party documents
|
||
* Otherwise, require all 12 documents (7 damaged + 5 guilty party)
|
||
*/
|
||
private getRequiredDocumentTypes(claimRequest: any): ClaimRequiredDocumentType[] {
|
||
const allTypes = Object.values(ClaimRequiredDocumentType);
|
||
|
||
// Check if it's CAR_BODY type
|
||
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
|
||
|
||
if (isCarBody) {
|
||
// Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
|
||
const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
|
||
const isAccidentWithOtherObject =
|
||
(firstPartyCarBody && firstPartyCarBody.object === true) ||
|
||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
|
||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
|
||
|
||
if (isAccidentWithOtherObject) {
|
||
this.logger.debug(
|
||
`[getRequiredDocumentTypes] CAR_BODY claim with other object detected. ` +
|
||
`Requiring only 7 damaged party documents for claim ${claimRequest._id}`,
|
||
);
|
||
// Only require damaged party documents (7 documents)
|
||
return allTypes.filter((type) => type.startsWith("damaged_"));
|
||
}
|
||
}
|
||
|
||
// Default: require all 12 documents
|
||
return allTypes;
|
||
}
|
||
|
||
async uploadRequiredDocument(
|
||
requestId: string,
|
||
documentType: ClaimRequiredDocumentType,
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(claimRequest.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
if (
|
||
String(claimRequest.blameFile.expertSubmitReply?.guiltyUserId) ===
|
||
effectiveUserId
|
||
) {
|
||
throw new ForbiddenException(
|
||
"User access denied. You are the guilty party in the blame file.",
|
||
);
|
||
}
|
||
|
||
if (claimRequest.currentStep !== ClaimStepsEnum.UploadRequiredDocuments) {
|
||
throw new BadRequestException(
|
||
`Claim is not in the required documents upload step. Current step: ${claimRequest.currentStep}`,
|
||
);
|
||
}
|
||
|
||
if (!file) {
|
||
throw new BadRequestException("File is required");
|
||
}
|
||
|
||
// Check if this document type has already been uploaded
|
||
const existingDocuments = await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
documentType: documentType,
|
||
});
|
||
|
||
// If document exists, delete the old one first (allow replacement)
|
||
if (existingDocuments.length > 0) {
|
||
for (const existingDoc of existingDocuments) {
|
||
// Delete the file from filesystem
|
||
try {
|
||
if (existingDoc.path && existsSync(existingDoc.path)) {
|
||
await unlink(existingDoc.path);
|
||
this.logger.log(
|
||
`Deleted old file: ${existingDoc.path} for document type ${documentType}`,
|
||
);
|
||
}
|
||
} catch (fileError) {
|
||
this.logger.warn(
|
||
`Failed to delete file ${existingDoc.path}: ${fileError.message}`,
|
||
);
|
||
// Continue even if file deletion fails
|
||
}
|
||
|
||
// Delete from the separate collection
|
||
await this.claimRequiredDocumentDbService.delete(existingDoc._id.toString());
|
||
// Remove from claim's requiredDocuments map
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$unset: {
|
||
[`requiredDocuments.${documentType}`]: "",
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Create the document record
|
||
const documentRecord = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(requestId),
|
||
documentType: documentType,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
// Add document ID to claim's requiredDocuments map using documentType as key
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
[`requiredDocuments.${documentType}`]: documentRecord._id,
|
||
},
|
||
},
|
||
);
|
||
|
||
// Check if all required documents have been uploaded
|
||
// For CAR_BODY with other object, only require 7 documents instead of 12
|
||
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
|
||
const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
|
||
const allUploaded = allRequiredTypes.every((type) =>
|
||
uploadedTypes.includes(type),
|
||
);
|
||
|
||
// If all documents are uploaded, move to next step
|
||
if (allUploaded) {
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.waitForDamageExpertComment,
|
||
claimStatus: ReqClaimStatus.UnChecked,
|
||
},
|
||
$push: {
|
||
steps: ClaimStepsEnum.UploadRequiredDocuments,
|
||
},
|
||
});
|
||
} else {
|
||
// Ensure status is set when entering this step (first document upload)
|
||
const currentClaim = await this.claimDbService.findOne(requestId);
|
||
if (currentClaim?.claimStatus !== ReqClaimStatus.UploadingRequiredDocuments) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
return {
|
||
message: "Document uploaded successfully",
|
||
documentId: documentRecord._id,
|
||
documentType: documentType,
|
||
allDocumentsUploaded: allUploaded,
|
||
nextStep: allUploaded ? ClaimStepsEnum.waitForDamageExpertComment : null,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in uploadRequiredDocument: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getRequiredDocumentsStatus(requestId: string) {
|
||
try {
|
||
const claimRequest = await this.claimDbService.findOne(requestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Get required document types based on claim type
|
||
const allRequiredTypes = this.getRequiredDocumentTypes(claimRequest);
|
||
const uploadedDocuments = await this.claimRequiredDocumentDbService.findAll({
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
|
||
const uploadedTypes = uploadedDocuments.map((doc) => doc.documentType);
|
||
const status = allRequiredTypes.map((type) => {
|
||
const doc = uploadedDocuments.find((d) => d.documentType === type);
|
||
return {
|
||
documentType: type,
|
||
uploaded: !!doc,
|
||
documentId: doc?._id || null,
|
||
fileName: doc?.fileName || null,
|
||
uploadedAt: doc?.uploadedAt || null,
|
||
};
|
||
});
|
||
|
||
return {
|
||
claimId: requestId,
|
||
currentStep: claimRequest.currentStep,
|
||
nextStep: claimRequest.nextStep,
|
||
allUploaded: allRequiredTypes.every((type) => uploadedTypes.includes(type)),
|
||
documents: status,
|
||
totalRequired: allRequiredTypes.length,
|
||
totalUploaded: uploadedDocuments.length,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error in getRequiredDocumentsStatus: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getImageRequiredList(requestId: string) {
|
||
return await new Promise((resolve, reject) => {
|
||
this.claimDbService
|
||
.findOne(requestId)
|
||
.then((cl) => {
|
||
if (!cl) {
|
||
throw new HttpException(
|
||
"Claim File Not Found",
|
||
HttpStatus.NOT_FOUND,
|
||
);
|
||
}
|
||
|
||
// Allow access if:
|
||
// 1. Currently in ImageRequired step (currentStep or nextStep is ImageRequired)
|
||
// 2. OR has completed ImageRequired and moved to next step (to allow viewing data after completion)
|
||
const isInImageRequiredStep =
|
||
cl.currentStep === ClaimStepsEnum.ImageRequired ||
|
||
cl.nextStep === ClaimStepsEnum.ImageRequired;
|
||
|
||
const hasCompletedImageRequired =
|
||
cl.steps &&
|
||
Array.isArray(cl.steps) &&
|
||
cl.steps.includes(ClaimStepsEnum.ImageRequired);
|
||
|
||
if (isInImageRequiredStep || hasCompletedImageRequired) {
|
||
this.logger.debug(
|
||
`[getImageRequiredList] Allowing access for claim ${requestId}. ` +
|
||
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
|
||
`hasCompletedImageRequired: ${hasCompletedImageRequired}`,
|
||
);
|
||
if (
|
||
cl.imageRequired.aroundTheCar &&
|
||
Array.isArray(cl.imageRequired.aroundTheCar)
|
||
) {
|
||
cl.imageRequired.aroundTheCar.forEach((carPart) => {
|
||
//@ts-ignore
|
||
delete carPart?.aiReport;
|
||
});
|
||
cl.imageRequired.selectPartOfCar.forEach((part) => {
|
||
delete part.aiReport;
|
||
});
|
||
}
|
||
resolve(new ImageRequiredDto(cl));
|
||
} else {
|
||
this.logger.warn(
|
||
`[getImageRequiredList] Access denied for claim ${requestId}. ` +
|
||
`currentStep: ${cl.currentStep}, nextStep: ${cl.nextStep}, ` +
|
||
`steps: ${JSON.stringify(cl.steps)}`,
|
||
);
|
||
throw new HttpException(
|
||
"This File is Not in ImageRequired Step",
|
||
HttpStatus.FORBIDDEN,
|
||
);
|
||
}
|
||
})
|
||
.catch((er) => {
|
||
this.logger.error(er);
|
||
if (er instanceof Error) {
|
||
reject(er);
|
||
} else {
|
||
reject(new Error(`Promise rejected with non-error value: ${er}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async setImageRequiredList(
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: any,
|
||
) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
|
||
let result = [];
|
||
const imageRequiredKeys = Object.keys(document.imageRequired);
|
||
|
||
if (partOfDamageId === undefined) {
|
||
if (document.videoCaptureId) {
|
||
throw new HttpException("Video already exists", HttpStatus.CONFLICT);
|
||
}
|
||
|
||
const videoId = await this.createVideoCapture(fileDetail, requestId);
|
||
await this.updateClaimWithVideoCapture(requestId, videoId);
|
||
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
|
||
} else if (partOfDamageId) {
|
||
await this.processDamageImages(
|
||
document,
|
||
requestId,
|
||
partOfDamageId,
|
||
fileDetail,
|
||
imageRequiredKeys,
|
||
result,
|
||
);
|
||
result.forEach((item) => {
|
||
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
|
||
item.aroundTheCar.forEach((carPart) => {
|
||
delete carPart.aiReport;
|
||
});
|
||
item.selectPartOfCar.forEach((part) => {
|
||
delete part.aiReport;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
async setDamageImage(
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: Express.Multer.File,
|
||
) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Image file is required.");
|
||
}
|
||
|
||
if (
|
||
document.currentStep !== ClaimStepsEnum.ImageRequired &&
|
||
document.nextStep !== ClaimStepsEnum.ImageRequired
|
||
) {
|
||
throw new BadRequestException(
|
||
`Claim is not in the ImageRequired step. Current step: ${document.currentStep}, Status: ${document.claimStatus}`,
|
||
);
|
||
}
|
||
|
||
const result = [];
|
||
const imageRequiredKeys = Object.keys(document.imageRequired);
|
||
|
||
// Process images - AI will be processed in background
|
||
await this.processDamageImages(
|
||
document,
|
||
requestId,
|
||
partOfDamageId,
|
||
fileDetail,
|
||
imageRequiredKeys,
|
||
result,
|
||
);
|
||
|
||
// Clean up the response - remove AI reports since they're processing in background
|
||
result.forEach((item) => {
|
||
if (item.aroundTheCar && Array.isArray(item.aroundTheCar)) {
|
||
item.aroundTheCar.forEach((carPart) => delete carPart.aiReport);
|
||
item.selectPartOfCar.forEach((part) => delete part.aiReport);
|
||
}
|
||
});
|
||
|
||
this.logger.log(
|
||
`[setDamageImage] Image uploaded successfully for part ${partOfDamageId}, request ${requestId}. AI processing started in background.`,
|
||
);
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
async setVideoCapture(requestId: string, fileDetail: Express.Multer.File) {
|
||
try {
|
||
const document = await this.claimDbService.findOneDocument(requestId);
|
||
if (!document) {
|
||
throw new HttpException("Claim file not found", HttpStatus.NOT_FOUND);
|
||
}
|
||
if (!fileDetail) {
|
||
throw new BadRequestException("Video file is required.");
|
||
}
|
||
if (document.videoCaptureId) {
|
||
throw new HttpException(
|
||
"A video has already been uploaded for this claim.",
|
||
HttpStatus.CONFLICT,
|
||
);
|
||
}
|
||
|
||
const videoId = await this.createVideoCapture(fileDetail, requestId);
|
||
await this.updateClaimWithVideoCapture(requestId, videoId);
|
||
|
||
return {
|
||
statusCode: HttpStatus.ACCEPTED,
|
||
message: "Video capture uploaded successfully.",
|
||
videoId: videoId,
|
||
};
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
private async createVideoCapture(fileDetail: any, requestId: string) {
|
||
const videoId = await this.videoCaptureDbService.create({
|
||
path: fileDetail.path,
|
||
filName: fileDetail.filename,
|
||
claimId: new Types.ObjectId(requestId),
|
||
});
|
||
return videoId;
|
||
}
|
||
|
||
private async updateClaimWithVideoCapture(requestId: string, videoId: any) {
|
||
if (
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: { videoCaptureId: videoId },
|
||
},
|
||
)
|
||
) {
|
||
throw new HttpException("Video capture uploaded", HttpStatus.ACCEPTED);
|
||
}
|
||
}
|
||
|
||
private async processAiRequestQueue(
|
||
tasks: (() => Promise<void>)[],
|
||
delayMs = 200,
|
||
): Promise<void> {
|
||
for (const task of tasks) {
|
||
await task();
|
||
await this.delay(delayMs);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process AI requests in the background without blocking the response
|
||
* This allows users to upload images quickly without waiting for AI processing
|
||
*/
|
||
private async processAiRequestQueueInBackground(
|
||
tasks: (() => Promise<void>)[],
|
||
requestId: string,
|
||
partId: string,
|
||
): Promise<void> {
|
||
if (tasks.length === 0) {
|
||
return;
|
||
}
|
||
|
||
this.logger.log(
|
||
`[BACKGROUND AI] Starting background AI processing for ${tasks.length} image(s), part ${partId}, request ${requestId}`,
|
||
);
|
||
|
||
// Process in background without blocking
|
||
setImmediate(async () => {
|
||
try {
|
||
await this.processAiRequestQueue(tasks);
|
||
this.logger.log(
|
||
`[BACKGROUND AI] Completed AI processing for part ${partId}, request ${requestId}`,
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[BACKGROUND AI] Failed to process AI requests for part ${partId}, request ${requestId}: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
// Don't throw - we're in background processing
|
||
}
|
||
});
|
||
}
|
||
|
||
private async handleAiSuccess(
|
||
response: any,
|
||
key: string,
|
||
partId: string,
|
||
requestId: string,
|
||
): Promise<void> {
|
||
this.logger.log(`[STEP 4/4] Processing AI response for part ${partId}, requestId: ${requestId}`);
|
||
|
||
// 1. Validate that we have the processed image
|
||
if (!response?.downloadLink) {
|
||
this.logger.error(`[ERROR] Missing processed image (downloadLink) in AI response`);
|
||
this.logger.error(`[ERROR] Part ID: ${partId}, Request ID: ${requestId}`);
|
||
this.logger.error(`[ERROR] Response keys: ${JSON.stringify(response ? Object.keys(response) : 'null')}`);
|
||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response, null, 2)}`);
|
||
throw new HttpException(
|
||
`AI response missing processed image (downloadLink) for part ${partId}`,
|
||
HttpStatus.BAD_GATEWAY,
|
||
);
|
||
}
|
||
|
||
this.logger.log(`[STEP 4/4] Processed image URL: ${response.downloadLink}`);
|
||
|
||
// 2. Safely access the nested properties.
|
||
const reports = response?.reports;
|
||
const reportText = reports?.distinct_damaged_parts_report?.report;
|
||
|
||
// 3. Log warning if reports are missing but continue with image
|
||
if (!reports) {
|
||
this.logger.warn(
|
||
`[WARNING] AI response for part ${partId} is missing the 'reports' object, but downloadLink exists.`,
|
||
);
|
||
this.logger.warn(`[WARNING] Will save image but without AI report data.`);
|
||
} else {
|
||
this.logger.log(`[STEP 4/4] AI reports present: ${reports ? 'yes' : 'no'}`);
|
||
}
|
||
|
||
// 4. Build the update payload.
|
||
const updatePayload = {
|
||
$set: {
|
||
[`imageRequired.${key}.$[elem].aiReport`]: reports || {},
|
||
[`imageRequired.${key}.$[elem].aiImage`]: response.downloadLink,
|
||
[`imageRequired.${key}.$[elem].aiReportText`]: reportText || "",
|
||
[`imageRequired.aiReportText`]: response.reportsText || "",
|
||
},
|
||
};
|
||
|
||
// 5. Update the database using arrayFilters.
|
||
try {
|
||
const updateResult = await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
updatePayload,
|
||
{
|
||
arrayFilters: [{ "elem.partId": partId }],
|
||
},
|
||
);
|
||
|
||
if (!updateResult) {
|
||
this.logger.error(`[ERROR] Database update failed - no document found or update failed`);
|
||
this.logger.error(`[ERROR] Request ID: ${requestId}, Part ID: ${partId}, Key: ${key}`);
|
||
throw new HttpException(
|
||
`Failed to update database with AI results for part ${partId}`,
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
|
||
this.logger.log(`[SUCCESS] Successfully updated AI report and image for part ${partId}`);
|
||
} catch (dbError) {
|
||
this.logger.error(`[ERROR] Database update error for part ${partId}:`);
|
||
this.logger.error(`[ERROR] ${dbError.message}`);
|
||
this.logger.error(`[ERROR] Stack: ${dbError.stack}`);
|
||
throw new HttpException(
|
||
`Database update failed: ${dbError.message}`,
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async retryAiRequest(
|
||
newImageDoc: any,
|
||
key: string,
|
||
partId: string,
|
||
requestId: string,
|
||
retries = 3,
|
||
): Promise<void> {
|
||
const attemptNumber = 4 - retries; // Track which attempt this is
|
||
this.logger.log(`[ATTEMPT ${attemptNumber}] Processing AI request for part ${partId}, requestId: ${requestId}`);
|
||
this.logger.log(`[ATTEMPT ${attemptNumber}] File path: ${newImageDoc.path}, File name: ${newImageDoc.fileName}`);
|
||
|
||
try {
|
||
// Use the new, robust AiService to send the request.
|
||
// Pass both path and fileName to the AI service
|
||
const response = await this.aiService.aiRequestImage({
|
||
path: newImageDoc.path,
|
||
fileName: newImageDoc.fileName,
|
||
});
|
||
|
||
// If the request succeeds, handle the success.
|
||
await this.handleAiSuccess(response, key, partId, requestId);
|
||
this.logger.log(`[SUCCESS] AI processing completed successfully for part ${partId}`);
|
||
} catch (error) {
|
||
// Extract error source from error message if available
|
||
const errorMessage = error.message || String(error);
|
||
const errorSource = errorMessage.includes('[ERROR]')
|
||
? errorMessage.split('[ERROR]')[1]?.split(']')[0] || 'UNKNOWN'
|
||
: 'UNKNOWN';
|
||
|
||
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Error source: ${errorSource}`);
|
||
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Part ID: ${partId}, Request ID: ${requestId}`);
|
||
this.logger.error(`[ATTEMPT ${attemptNumber} FAILED] Error: ${errorMessage}`);
|
||
|
||
if (retries > 0) {
|
||
this.logger.warn(
|
||
`⚠️ Retrying AI request for part ${partId}... Attempts left: ${retries - 1}`,
|
||
);
|
||
await this.delay(1000); // Wait before retrying
|
||
return this.retryAiRequest(
|
||
newImageDoc,
|
||
key,
|
||
partId,
|
||
requestId,
|
||
retries - 1,
|
||
);
|
||
} else {
|
||
this.logger.error(`🔴 [FINAL FAILURE] AI request failed after all retries`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Request ID: ${requestId}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Part ID: ${partId}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] File path: ${newImageDoc.path}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Error source: ${errorSource}`);
|
||
this.logger.error(`🔴 [FINAL FAILURE] Final error: ${errorMessage}`);
|
||
if (error.stack) {
|
||
this.logger.error(`🔴 [FINAL FAILURE] Stack trace: ${error.stack}`);
|
||
}
|
||
// Optional: Update the claim status to indicate an AI failure.
|
||
// You could throw here if you want to fail the entire operation
|
||
throw error; // Re-throw to propagate the error
|
||
}
|
||
}
|
||
}
|
||
|
||
private async processDamageImages(
|
||
document: any,
|
||
requestId: string,
|
||
partOfDamageId: string,
|
||
fileDetail: any,
|
||
imageRequiredKeys: string[],
|
||
result: any[],
|
||
) {
|
||
const aiRequestQueue = [];
|
||
|
||
for (const key of imageRequiredKeys) {
|
||
const images = document.imageRequired[key];
|
||
for (const img of images) {
|
||
if (img.partId === partOfDamageId) {
|
||
const newImageDoc = await this.createDamageImage(
|
||
fileDetail,
|
||
requestId,
|
||
);
|
||
|
||
// Queue AI request for background processing
|
||
const aiRequestTask = () =>
|
||
this.retryAiRequest(newImageDoc, key, partOfDamageId, requestId);
|
||
|
||
aiRequestQueue.push(aiRequestTask);
|
||
|
||
// Update the document immediately with the uploaded image
|
||
await this.updateImageRequiredDocument(
|
||
requestId,
|
||
key,
|
||
partOfDamageId,
|
||
newImageDoc,
|
||
);
|
||
const updatedDocs =
|
||
await this.claimDbService.findOneDocument(requestId);
|
||
this.handleImageUpdateResult(result, updatedDocs, requestId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process AI requests in the background (fire and forget)
|
||
// Don't await - let it run asynchronously so user doesn't have to wait
|
||
this.processAiRequestQueueInBackground(aiRequestQueue, requestId, partOfDamageId)
|
||
.catch((error) => {
|
||
// Log errors but don't fail the upload
|
||
this.logger.error(
|
||
`[BACKGROUND AI] Error processing AI requests for part ${partOfDamageId}, request ${requestId}: ${error.message}`,
|
||
error.stack,
|
||
);
|
||
});
|
||
}
|
||
|
||
private async updateImageRequiredDocument(
|
||
requestId,
|
||
key,
|
||
partOfDamageId,
|
||
newImageDoc,
|
||
) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$set: {
|
||
[`imageRequired.${key}.$[elem].upload`]: true,
|
||
[`imageRequired.${key}.$[elem].imageId`]: newImageDoc._id,
|
||
},
|
||
},
|
||
{
|
||
arrayFilters: [{ "elem.partId": partOfDamageId }],
|
||
},
|
||
);
|
||
}
|
||
|
||
private async createDamageImage(fileDetail, requestId) {
|
||
return await this.damageImageDbService.create({
|
||
path: fileDetail.path,
|
||
fileName: fileDetail.filename,
|
||
claimId: requestId,
|
||
});
|
||
}
|
||
|
||
private async handleImageUpdateResult(result, updatedDocs, requestId) {
|
||
const aroundCarChecked = updatedDocs.imageRequired.aroundTheCar.filter(
|
||
(r) => !r.upload,
|
||
);
|
||
const selectPartOfCar = updatedDocs.imageRequired.selectPartOfCar.filter(
|
||
(r) => !r.upload,
|
||
);
|
||
|
||
result.push(updatedDocs.imageRequired);
|
||
if (aroundCarChecked.length === 0 && selectPartOfCar.length === 0) {
|
||
await this.claimDbService.findOneAndUpdate(
|
||
{ _id: new Types.ObjectId(requestId) },
|
||
{
|
||
$push: {
|
||
steps: ClaimStepsEnum.ImageRequired,
|
||
},
|
||
$set: {
|
||
currentStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
nextStep: ClaimStepsEnum.UploadRequiredDocuments,
|
||
claimStatus: ReqClaimStatus.UploadingRequiredDocuments,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
async myRequests(currentUser) {
|
||
// For users, return their own claim files
|
||
if (currentUser.role === RoleEnum.USER) {
|
||
const claimFile = await this.claimDbService.findAllByAnyFilter({
|
||
userId: new Types.ObjectId(currentUser.sub),
|
||
});
|
||
if (!claimFile) throw new NotFoundException("claim file not found");
|
||
return new MyRequestsDto(claimFile);
|
||
}
|
||
|
||
// For experts, return IN_PERSON claim files they initiated
|
||
const expertRoles = [
|
||
RoleEnum.EXPERT,
|
||
RoleEnum.DAMAGE_EXPERT,
|
||
RoleEnum.FIELD_EXPERT,
|
||
];
|
||
if (expertRoles.includes(currentUser.role)) {
|
||
const allClaims = await this.claimDbService.findAllByAnyFilter({});
|
||
const expertClaims = allClaims.filter((claim) => {
|
||
const blameFile = claim.blameFile;
|
||
return (
|
||
blameFile?.expertInitiated &&
|
||
blameFile?.creationMethod === "IN_PERSON" &&
|
||
String(blameFile.initiatedBy) === currentUser.sub
|
||
);
|
||
});
|
||
if (expertClaims.length === 0) {
|
||
throw new NotFoundException("No IN_PERSON claim files found");
|
||
}
|
||
return new MyRequestsDto(expertClaims);
|
||
}
|
||
|
||
throw new ForbiddenException("Invalid role");
|
||
}
|
||
|
||
private waitingForUserResendRS(req: ClaimRequestManagementModel) {
|
||
if (req.damageExpertResend) {
|
||
return new ClaimRequestDtoRs(
|
||
req.damageExpertResend as any,
|
||
"این درخواست نیاز به ارسال مدارک مجدد دارد ",
|
||
ReqClaimStatus.WaitingForUserToResend,
|
||
);
|
||
} else {
|
||
throw new ForbiddenException("damageExpertResend doesnt exist");
|
||
}
|
||
}
|
||
|
||
private closeRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست بسته شده است ",
|
||
ReqClaimStatus.CloseRequest,
|
||
);
|
||
}
|
||
|
||
private checkAgainRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"در انتظار پاسخ مجدد کارشناس",
|
||
ReqClaimStatus.CheckAgain,
|
||
);
|
||
}
|
||
|
||
private userPendingRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این پرونده از طرف شما کامل نشده میباشد",
|
||
ReqClaimStatus.UserPending,
|
||
);
|
||
}
|
||
|
||
private reviewRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
`درخواست درحال برسی توسط کارشناس میباشد ${req.unlockTime} - زمان باقی مانده لطفا صبر کنید`,
|
||
ReqClaimStatus.ReviewRequest,
|
||
);
|
||
}
|
||
|
||
private checkedRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست بررسی شده است",
|
||
ReqClaimStatus.CheckedRequest,
|
||
);
|
||
}
|
||
|
||
private uncheckedRequestRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست در انتظار بررسی می باشد.",
|
||
ReqClaimStatus.UnChecked,
|
||
);
|
||
}
|
||
|
||
private waitingForUserCompletedRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"این درخواست نیازمند تکمیل توسط کاربر می باشد.",
|
||
ReqClaimStatus.WaitingForUserCompleted,
|
||
);
|
||
}
|
||
|
||
private inPersonVisitRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"مراجعه حضوری",
|
||
ReqClaimStatus.InPersonVisit,
|
||
);
|
||
}
|
||
|
||
private responseController(request: ClaimRequestManagementModel) {
|
||
switch (request.claimStatus) {
|
||
case ReqClaimStatus.WaitingForUserCompleted:
|
||
return this.waitingForUserCompletedRS(request);
|
||
case ReqClaimStatus.UnChecked:
|
||
return this.uncheckedRequestRS(request);
|
||
case ReqClaimStatus.CheckedRequest:
|
||
return this.checkedRequestRS(request);
|
||
case ReqClaimStatus.ReviewRequest:
|
||
return this.reviewRequestRS(request);
|
||
case ReqClaimStatus.UserPending:
|
||
return this.userPendingRS(request);
|
||
case ReqClaimStatus.CloseRequest:
|
||
return this.closeRequestRS(request);
|
||
case ReqClaimStatus.WaitingForUserToResend:
|
||
return this.waitingForUserResendRS(request);
|
||
case ReqClaimStatus.CheckAgain:
|
||
return this.checkAgainRS(request);
|
||
case ReqClaimStatus.InPersonVisit:
|
||
return this.inPersonVisitRS(request);
|
||
case ReqClaimStatus.PendingFactorValidation:
|
||
return this.pendingFactorValidationRS(request);
|
||
case ReqClaimStatus.FactorRejected:
|
||
return this.factorRejectedRS(request);
|
||
case ReqClaimStatus.PendingFactorUpload:
|
||
return this.pendingFactorUploadRS(request);
|
||
case ReqClaimStatus.UploadingRequiredDocuments:
|
||
return this.uploadingRequiredDocumentsRS(request);
|
||
default:
|
||
return "Unknown status";
|
||
}
|
||
}
|
||
|
||
private pendingFactorUploadRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر آپلود فاکتور توسط کاربر",
|
||
ReqClaimStatus.PendingFactorUpload,
|
||
);
|
||
}
|
||
|
||
private pendingFactorValidationRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر تایید فاکتور توسط کارشناس",
|
||
ReqClaimStatus.PendingFactorValidation,
|
||
);
|
||
}
|
||
|
||
private factorRejectedRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"فاکتور ریجکت شده و باید دوباره ارسال شود",
|
||
ReqClaimStatus.FactorRejected,
|
||
);
|
||
}
|
||
|
||
private uploadingRequiredDocumentsRS(req: ClaimRequestManagementModel) {
|
||
return new ClaimRequestDtoRs(
|
||
req,
|
||
"منتظر آپلود مدارک مورد نیاز توسط کاربر",
|
||
ReqClaimStatus.UploadingRequiredDocuments,
|
||
);
|
||
}
|
||
|
||
async requestDetails(requestId: string, user?: any) {
|
||
const request = await this.claimDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
// Verify access if user is provided
|
||
if (user) {
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, user);
|
||
if (String(request.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
}
|
||
|
||
if (request.imageRequired) {
|
||
request.imageRequired.selectPartOfCar.forEach((r) => {
|
||
delete r.aiReport;
|
||
});
|
||
|
||
request.imageRequired.aroundTheCar.forEach((r) => {
|
||
//@ts-ignore
|
||
delete r.aiReport;
|
||
});
|
||
}
|
||
|
||
// Populate factorLink ObjectIds with actual file URLs
|
||
await this.populateFactorLinks(request.damageExpertReply);
|
||
await this.populateFactorLinks(request.damageExpertReplyFinal);
|
||
|
||
return this.responseController(request);
|
||
}
|
||
|
||
/**
|
||
* Converts factorLink ObjectIds to file URLs
|
||
*/
|
||
private async populateFactorLinks(reply: any): Promise<void> {
|
||
if (!reply || !Array.isArray(reply.parts)) {
|
||
return;
|
||
}
|
||
|
||
for (const part of reply.parts) {
|
||
if (part.factorLink && Types.ObjectId.isValid(part.factorLink)) {
|
||
const factorDoc = await this.claimFactorsImageDbService.findById(
|
||
part.factorLink.toString(),
|
||
);
|
||
|
||
if (factorDoc) {
|
||
part.factorLink = buildFileLink(factorDoc.path);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 response mapper:
|
||
* - convert factorLink ObjectId -> public URL
|
||
* - enrich daghi.branchId with branchName when available
|
||
*/
|
||
private async mapEvaluationForClient(evaluation: any): Promise<any | undefined> {
|
||
if (!evaluation) return undefined;
|
||
|
||
const mapReply = async (reply: any) => {
|
||
if (!reply) return reply;
|
||
|
||
const mapped = {
|
||
...(typeof reply?.toObject === "function" ? reply.toObject() : reply),
|
||
};
|
||
const parts = Array.isArray(mapped.parts) ? mapped.parts : [];
|
||
|
||
const factorIds = new Set<string>();
|
||
const branchIds = new Set<string>();
|
||
for (const p of parts) {
|
||
if (p?.factorLink && Types.ObjectId.isValid(String(p.factorLink))) {
|
||
factorIds.add(String(p.factorLink));
|
||
}
|
||
const daghi = p?.daghi;
|
||
const rawBranchId =
|
||
daghi && typeof daghi === "object" ? (daghi as any).branchId : undefined;
|
||
if (rawBranchId && Types.ObjectId.isValid(String(rawBranchId))) {
|
||
branchIds.add(String(rawBranchId));
|
||
}
|
||
}
|
||
|
||
const factorMap = new Map<string, string>();
|
||
await Promise.all(
|
||
Array.from(factorIds).map(async (id) => {
|
||
const factorDoc = await this.claimFactorsImageDbService.findById(id);
|
||
if (factorDoc?.path) {
|
||
factorMap.set(id, buildFileLink(factorDoc.path));
|
||
}
|
||
}),
|
||
);
|
||
|
||
const branchMap = new Map<string, string>();
|
||
await Promise.all(
|
||
Array.from(branchIds).map(async (id) => {
|
||
const branch = await this.branchDbService.findById(id);
|
||
if (branch?.name) {
|
||
branchMap.set(id, branch.name);
|
||
}
|
||
}),
|
||
);
|
||
|
||
mapped.parts = parts.map((part: any) => {
|
||
const nextPart = { ...part };
|
||
|
||
if (nextPart.factorLink && Types.ObjectId.isValid(String(nextPart.factorLink))) {
|
||
const link = factorMap.get(String(nextPart.factorLink));
|
||
if (link) nextPart.factorLink = link;
|
||
}
|
||
|
||
const daghi = nextPart.daghi;
|
||
if (daghi && typeof daghi === "object" && !Array.isArray(daghi)) {
|
||
const branchId = (daghi as any).branchId;
|
||
const branchName =
|
||
branchId && Types.ObjectId.isValid(String(branchId))
|
||
? branchMap.get(String(branchId))
|
||
: undefined;
|
||
nextPart.daghi = {
|
||
...daghi,
|
||
...(branchName ? { branchName } : {}),
|
||
};
|
||
}
|
||
|
||
return nextPart;
|
||
});
|
||
|
||
return mapped;
|
||
};
|
||
|
||
return {
|
||
...evaluation,
|
||
damageExpertReply: await mapReply(evaluation.damageExpertReply),
|
||
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
|
||
};
|
||
}
|
||
|
||
async submitUserReply(
|
||
requestId: string,
|
||
body: UserCommentDto,
|
||
file?: Express.Multer.File,
|
||
actor?: any,
|
||
) {
|
||
try {
|
||
const request = await this.claimDbService.findOne(requestId);
|
||
|
||
if (!this.isValidRequest(request)) {
|
||
throw new HttpException("Invalid request", HttpStatus.NOT_ACCEPTABLE);
|
||
}
|
||
|
||
// Verify access if actor is provided
|
||
if (actor) {
|
||
const effectiveUserId = await this.getEffectiveUserId(requestId, actor);
|
||
if (String(request.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
}
|
||
|
||
const finalReply =
|
||
request.damageExpertReplyFinal || request.damageExpertReply;
|
||
|
||
const replyFieldKey = request.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
|
||
const parts = finalReply?.parts || [];
|
||
const missingFactors = parts
|
||
.filter((p) => p.factorNeeded === true && !p.factorLink)
|
||
.map((p) => p.carPartDamage || p.partId);
|
||
|
||
if (missingFactors.length > 0) {
|
||
throw new HttpException(
|
||
`Missing factor images for parts: ${missingFactors.join(", ")}`,
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
const userComment = {
|
||
...body,
|
||
...(file ? { fileLink: buildFileLink(file.path) } : {}),
|
||
};
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
[`${replyFieldKey}.userComment`]: {
|
||
...userComment,
|
||
time: Date.now(),
|
||
},
|
||
},
|
||
});
|
||
|
||
if (body.isAccept) {
|
||
const { partsFactorDetail, partsNeedFactor } =
|
||
await this.processAcceptance(request, file, requestId, body);
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
claimStatus: ReqClaimStatus.CloseRequest,
|
||
},
|
||
});
|
||
|
||
return new SubmitUserReplyDtoRs(
|
||
request,
|
||
partsFactorDetail,
|
||
partsNeedFactor,
|
||
);
|
||
} else {
|
||
this.handleRejection(request, body.isAccept, requestId);
|
||
return { requestId, isAccept: body.isAccept };
|
||
}
|
||
} catch (error) {
|
||
this.handleError(error);
|
||
}
|
||
}
|
||
|
||
private isValidRequest(request: any): boolean {
|
||
return (
|
||
request.damageExpertReply &&
|
||
request.claimStatus === ReqClaimStatus.CheckedRequest
|
||
);
|
||
}
|
||
|
||
private async processAcceptance(
|
||
request: any,
|
||
file: any,
|
||
requestId: string,
|
||
body: UserCommentDto,
|
||
): Promise<{ partsFactorDetail: any[]; partsNeedFactor: boolean }> {
|
||
const partsFactorDetail = [];
|
||
let partsNeedFactor = false;
|
||
|
||
for (let i = 0; i < request.damageExpertReply.parts.length; i++) {
|
||
const part = request.damageExpertReply.parts[i];
|
||
|
||
if (!part || part.partCost === undefined) continue;
|
||
|
||
if (part.partCost === "FACTOR") {
|
||
partsNeedFactor = await this.handleFactorPart(
|
||
requestId,
|
||
i,
|
||
part,
|
||
partsFactorDetail,
|
||
partsNeedFactor,
|
||
);
|
||
} else {
|
||
await this.handleNonFactorPart(requestId, file, body.isAccept, part);
|
||
}
|
||
}
|
||
|
||
return { partsFactorDetail, partsNeedFactor };
|
||
}
|
||
|
||
private async handleFactorPart(
|
||
requestId: string,
|
||
i: number,
|
||
part: any,
|
||
partsFactorDetail: any[],
|
||
partsNeedFactor: boolean,
|
||
): Promise<boolean> {
|
||
if (part.needFactor === undefined) {
|
||
const uId = uuidv4();
|
||
partsNeedFactor = true;
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
[`damageExpertReply.parts.${i}.needFactor`]: true,
|
||
[`damageExpertReply.parts.${i}.partId`]: uId,
|
||
},
|
||
});
|
||
|
||
partsFactorDetail.push({ partId: uId, ...part });
|
||
} else {
|
||
partsFactorDetail.push(part);
|
||
}
|
||
|
||
return partsNeedFactor;
|
||
}
|
||
|
||
private async handleNonFactorPart(
|
||
requestId: string,
|
||
file: any,
|
||
isAccept: boolean,
|
||
part: any,
|
||
): Promise<void> {
|
||
const sign = await this.claimSignDbService.create(file);
|
||
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
"damageExpertReply.userComment.isAccept": isAccept,
|
||
"damageExpertReply.userComment.signId": sign["_id"],
|
||
},
|
||
});
|
||
}
|
||
|
||
private async handleRejection(
|
||
request: any,
|
||
isAccept: boolean,
|
||
requestId: string,
|
||
): Promise<void> {
|
||
await this.claimDbService.findAndUpdate(requestId, {
|
||
$set: {
|
||
"damageExpertReply.userComment.isAccept": isAccept,
|
||
"damageExpertReply.userComment.signId": null,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Allows the damaged user to rate their claim file after completion.
|
||
* Only the claim owner (damaged user) can submit this rating and only
|
||
* when the claim is in CloseRequest status.
|
||
*/
|
||
async addUserRating(
|
||
claimRequestId: string,
|
||
actor: any,
|
||
ratingDto: UserRatingDto,
|
||
): Promise<UserClaimRating> {
|
||
const claim = await this.claimDbService.findOne(claimRequestId);
|
||
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Only the damaged user (claim owner) can rate the claim
|
||
const actorId = actor?.sub;
|
||
if (!actorId || String(claim.userId) !== actorId) {
|
||
throw new ForbiddenException(
|
||
"Only the claim owner can submit a satisfaction rating.",
|
||
);
|
||
}
|
||
|
||
// Rating is only allowed after the claim is fully closed
|
||
if (claim.claimStatus !== ReqClaimStatus.CloseRequest) {
|
||
throw new BadRequestException(
|
||
"You can only rate a claim after it has been closed.",
|
||
);
|
||
}
|
||
|
||
const userRating: UserClaimRating = {
|
||
progressSpeed: ratingDto.progressSpeed,
|
||
registrationEase: ratingDto.registrationEase,
|
||
overallEvaluation: ratingDto.overallEvaluation,
|
||
comment: ratingDto.comment,
|
||
createdAt: new Date(),
|
||
};
|
||
|
||
await this.claimDbService.findAndUpdate(claimRequestId, {
|
||
$set: { userRating },
|
||
});
|
||
|
||
return userRating;
|
||
}
|
||
|
||
async uploadClaimFactor(
|
||
claimId: string,
|
||
partId: string,
|
||
file: Express.Multer.File,
|
||
actor: any,
|
||
) {
|
||
try {
|
||
if (!file) {
|
||
throw new BadRequestException("A factor file is required for upload.");
|
||
}
|
||
|
||
const claim = await this.claimRequestManagementDbService.findOne(claimId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
|
||
if (String(claim.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
const finalReply =
|
||
claim.damageExpertReplyFinal || claim.damageExpertReply;
|
||
const replyFieldKey = claim.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
|
||
if (!finalReply) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
const part = finalReply.parts?.find((p) => p.partId === String(partId));
|
||
if (!part) {
|
||
throw new BadRequestException(
|
||
"The specified part was not found in the expert's reply.",
|
||
);
|
||
}
|
||
|
||
const factorRecord = await this.claimFactorsImageDbService.create({
|
||
claimId,
|
||
partId,
|
||
partName: part.carPartDamage,
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
const updatedClaim =
|
||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||
{
|
||
_id: new Types.ObjectId(claimId),
|
||
[`${replyFieldKey}.parts.partId`]: partId,
|
||
},
|
||
{
|
||
$set: {
|
||
[`${replyFieldKey}.parts.$.factorLink`]: factorRecord._id,
|
||
[`${replyFieldKey}.parts.$.factorStatus`]: FactorStatus.PENDING,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new NotFoundException(
|
||
"Could not find the specific part to update in the database.",
|
||
);
|
||
}
|
||
|
||
await this.checkAndTransitionStatusAfterUpload(claimId);
|
||
|
||
return {
|
||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||
factorId: factorRecord._id,
|
||
factorLink: buildFileLink(file.path),
|
||
url: buildFileLink(file.path),
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error during factor upload for claim ${claimId}:`,
|
||
error.stack,
|
||
);
|
||
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred during factor upload.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase).
|
||
* Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`.
|
||
*/
|
||
async uploadClaimFactorV2(
|
||
claimRequestId: string,
|
||
partId: string,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
) {
|
||
try {
|
||
if (!file) {
|
||
throw new BadRequestException("A factor file is required for upload.");
|
||
}
|
||
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claim,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
const replyKey = claim.evaluation?.damageExpertReplyFinal
|
||
? "damageExpertReplyFinal"
|
||
: "damageExpertReply";
|
||
const finalReply =
|
||
replyKey === "damageExpertReplyFinal"
|
||
? claim.evaluation?.damageExpertReplyFinal
|
||
: claim.evaluation?.damageExpertReply;
|
||
|
||
if (!finalReply?.parts?.length) {
|
||
throw new BadRequestException("No expert reply found in this claim.");
|
||
}
|
||
|
||
const part = finalReply.parts.find((p) => String(p.partId) === String(partId));
|
||
if (!part) {
|
||
throw new BadRequestException(
|
||
"The specified part was not found in the expert's reply.",
|
||
);
|
||
}
|
||
|
||
if (!part.factorNeeded) {
|
||
throw new BadRequestException("This part does not require a factor upload.");
|
||
}
|
||
|
||
if (part.factorLink) {
|
||
throw new ConflictException(
|
||
"A factor has already been uploaded for this part.",
|
||
);
|
||
}
|
||
|
||
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
|
||
throw new BadRequestException(
|
||
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
|
||
);
|
||
}
|
||
|
||
const partNameForRecord = part.carPartDamage ?? part.partId;
|
||
|
||
const factorRecord = await this.claimFactorsImageDbService.create({
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
partId: String(partId),
|
||
partName: partNameForRecord,
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
const pathPrefix = `evaluation.${replyKey}.parts`;
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findOneAndUpdate(
|
||
{
|
||
_id: new Types.ObjectId(claimRequestId),
|
||
[`${pathPrefix}.partId`]: partId,
|
||
},
|
||
{
|
||
$set: {
|
||
[`${pathPrefix}.$.factorLink`]: factorRecord._id,
|
||
[`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING,
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new NotFoundException(
|
||
"Could not find the specific part to update in the database.",
|
||
);
|
||
}
|
||
|
||
await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId);
|
||
|
||
return {
|
||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||
factorId: factorRecord._id,
|
||
url: buildFileLink(file.path),
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Error during V2 factor upload for claim ${claimRequestId}:`,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
|
||
if (error instanceof HttpException) {
|
||
throw error;
|
||
}
|
||
|
||
throw new HttpException(
|
||
"An internal server error occurred during factor upload.",
|
||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation).
|
||
*/
|
||
private async checkAndTransitionStatusAfterFactorUploadV2(
|
||
claimRequestId: string,
|
||
): Promise<void> {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim?.evaluation) {
|
||
return;
|
||
}
|
||
|
||
const finalReply =
|
||
claim.evaluation.damageExpertReplyFinal ||
|
||
claim.evaluation.damageExpertReply;
|
||
|
||
if (!finalReply?.parts) {
|
||
return;
|
||
}
|
||
|
||
const requiredFactorParts = finalReply.parts.filter((p) => p.factorNeeded === true);
|
||
if (requiredFactorParts.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const allFactorsAreUploaded = requiredFactorParts.every((p) => !!p.factorLink);
|
||
if (!allFactorsAreUploaded) {
|
||
return;
|
||
}
|
||
|
||
this.logger.log(
|
||
`All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`,
|
||
);
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
|
||
$push: {
|
||
history: {
|
||
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
|
||
timestamp: new Date(),
|
||
metadata: { claimRequestId },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
private async checkAndTransitionStatusAfterUpload(
|
||
claimId: string,
|
||
): Promise<void> {
|
||
// Re-fetch the latest state of the claim
|
||
const claim = await this.claimRequestManagementDbService.findOne(claimId);
|
||
const finalReply = claim.damageExpertReplyFinal || claim.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) {
|
||
this.logger.log(
|
||
`All required factors for claim ${claimId} have been uploaded. Updating status.`,
|
||
);
|
||
await this.claimRequestManagementDbService.findByIdAndUpdate(claimId, {
|
||
$set: {
|
||
claimStatus: ReqClaimStatus.PendingFactorValidation,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
private async resendDocumentsController(claimFile, query, file) {
|
||
if (!Object.keys(query).includes("documentName")) {
|
||
throw new HttpException("use documentName", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
const fileRequired = claimFile.damageExpertResend.resendDocuments;
|
||
if (!fileRequired.includes(query.documentName)) {
|
||
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
try {
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{
|
||
$push: {
|
||
"userResendDocuments.documents": {
|
||
[query.documentName]: buildFileLink(file.path),
|
||
},
|
||
},
|
||
},
|
||
{ new: true },
|
||
);
|
||
|
||
const existingKeys = updated.userResendDocuments.documents.flatMap(
|
||
Object.keys,
|
||
);
|
||
const allExist = fileRequired.every((key) => existingKeys.includes(key));
|
||
|
||
if (allExist) {
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
|
||
);
|
||
}
|
||
|
||
return {
|
||
allExist,
|
||
updated: updated.userResendDocuments.documents,
|
||
fileRequired,
|
||
};
|
||
} catch (err) {
|
||
this.logger.error(err);
|
||
return new HttpException(err.message, err.status);
|
||
}
|
||
}
|
||
|
||
private async resendCarParts(claimFile, query, file) {
|
||
if (!Object.keys(query).includes("partId")) {
|
||
throw new HttpException(
|
||
"use resendCarParts query string",
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
if (!Object.keys(query).includes("side")) {
|
||
throw new HttpException(
|
||
"side is required for car parts",
|
||
HttpStatus.BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
const fileRequired = claimFile.damageExpertResend.resendCarParts.map(
|
||
(r) => r.partId,
|
||
);
|
||
if (!fileRequired.includes(query.partId)) {
|
||
throw new HttpException("wrong required file", HttpStatus.BAD_REQUEST);
|
||
}
|
||
|
||
try {
|
||
const fileEntry = {
|
||
[query.partId]: {
|
||
link: buildFileLink(file.path),
|
||
side: query.side,
|
||
},
|
||
};
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $push: { "userResendDocuments.carParts": fileEntry } },
|
||
{ new: true },
|
||
);
|
||
|
||
const existingKeys = updated.userResendDocuments.carParts.flatMap(
|
||
Object.keys,
|
||
);
|
||
const allExist = fileRequired.every((key) => existingKeys.includes(key));
|
||
|
||
if (allExist) {
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
String(claimFile._id),
|
||
{ $set: { claimStatus: ReqClaimStatus.CheckAgain } },
|
||
);
|
||
}
|
||
|
||
return {
|
||
allExist,
|
||
updated: updated.userResendDocuments.carParts,
|
||
fileRequired,
|
||
};
|
||
} catch (err) {
|
||
this.logger.error(err);
|
||
return new HttpException(err.message, err.status);
|
||
}
|
||
}
|
||
|
||
async resendFiles(claimId, file, query, actor) {
|
||
let claimFile = await this.claimRequestManagementDbService.findOne(claimId);
|
||
if (!claimFile) {
|
||
throw new NotFoundException("Claim file not found");
|
||
}
|
||
|
||
// Verify access: user must own the claim, or expert must be accessing IN_PERSON file
|
||
const effectiveUserId = await this.getEffectiveUserId(claimId, actor);
|
||
if (String(claimFile.userId) !== effectiveUserId) {
|
||
throw new ForbiddenException("You do not have access to this claim file");
|
||
}
|
||
|
||
switch (query.fields) {
|
||
case "resendDocuments":
|
||
return this.resendDocumentsController(claimFile, query, file);
|
||
case "resendCarParts":
|
||
return this.resendCarParts(claimFile, query, file);
|
||
default:
|
||
return new HttpException("wrong query string", HttpStatus.BAD_REQUEST);
|
||
}
|
||
}
|
||
|
||
async handleUserObjectionAndParts(
|
||
claimRequestId: string,
|
||
userObjectionDto: UserObjectionDto,
|
||
) {
|
||
const claimRequest = await this.claimDbService.findOne(claimRequestId);
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
if (
|
||
claimRequest.claimStatus !== ReqClaimStatus.WaitingForUserToResend &&
|
||
claimRequest.claimStatus !== ReqClaimStatus.CheckedRequest
|
||
) {
|
||
throw new BadRequestException(
|
||
"درخواست شما در این حالت نمی تواند انجام شود",
|
||
);
|
||
}
|
||
|
||
if (claimRequest.objection)
|
||
throw new BadRequestException("Objection already exists!");
|
||
|
||
const updatePayload: any = {
|
||
objection: userObjectionDto,
|
||
};
|
||
|
||
if (userObjectionDto.newParts?.length) {
|
||
const newPartsWithIds = userObjectionDto.newParts.map((part) => ({
|
||
...part,
|
||
partId: part.partId || new Types.ObjectId(),
|
||
}));
|
||
|
||
updatePayload["newParts"] = newPartsWithIds;
|
||
}
|
||
|
||
const updated = await this.claimDbService.findAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
...updatePayload,
|
||
claimStatus: ReqClaimStatus.CheckAgain,
|
||
},
|
||
{ new: true },
|
||
);
|
||
|
||
return updated.objection;
|
||
}
|
||
|
||
async inPersonVisit(requestId: string, branchId: string, actorDetail: any) {
|
||
const request =
|
||
await this.claimRequestManagementDbService.findOne(requestId);
|
||
if (!request) {
|
||
throw new NotFoundException("Claim not found");
|
||
}
|
||
|
||
const branch = await this.branchDbService.findById(branchId);
|
||
if (!branch) {
|
||
throw new NotFoundException(`Branch with ID ${branchId} not found.`);
|
||
}
|
||
|
||
if (String(request.userClientKey) !== String(branch.clientKey)) {
|
||
throw new ForbiddenException(
|
||
"This branch does not belong to the insurer of this claim.",
|
||
);
|
||
}
|
||
|
||
const updated = await this.claimRequestManagementDbService.findAndUpdate(
|
||
requestId,
|
||
{
|
||
claimStatus: ReqClaimStatus.InPersonVisit,
|
||
visitLocation: `Branch ${branch.name} at ${branch.address}`,
|
||
},
|
||
);
|
||
|
||
if (
|
||
Types.ObjectId.isValid(String(actorDetail.sub)) &&
|
||
Types.ObjectId.isValid(String(request.userClientKey)) &&
|
||
Types.ObjectId.isValid(String(requestId))
|
||
) {
|
||
await this.expertFileActivityDbService.recordEvent({
|
||
expertId: String(actorDetail.sub),
|
||
tenantId: String(request.userClientKey),
|
||
fileId: String(requestId),
|
||
fileType: ExpertFileKind.CLAIM,
|
||
eventType: ExpertFileActivityType.HANDLED,
|
||
idempotencyKey: `claim:${requestId}:handled:inperson:${actorDetail.sub}`,
|
||
});
|
||
}
|
||
|
||
return updated;
|
||
}
|
||
|
||
async retrieveInsuranceBranches(insuranceId: string) {
|
||
return await this.branchDbService.findAll(insuranceId);
|
||
}
|
||
|
||
private handleError(error) {
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
|
||
/**
|
||
* Convert Gregorian date to Persian date format (1404/08/13)
|
||
*/
|
||
private convertToPersianDate(date: Date | string): string {
|
||
const d = new Date(date);
|
||
const persianDate = new Date(d).toLocaleDateString("fa-IR", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
// Convert Persian digits to English and format as YYYY/MM/DD
|
||
const persianToEnglish: { [key: string]: string } = {
|
||
"۰": "0",
|
||
"۱": "1",
|
||
"۲": "2",
|
||
"۳": "3",
|
||
"۴": "4",
|
||
"۵": "5",
|
||
"۶": "6",
|
||
"۷": "7",
|
||
"۸": "8",
|
||
"۹": "9",
|
||
};
|
||
return persianDate
|
||
.split("/")
|
||
.map((part) =>
|
||
part
|
||
.split("")
|
||
.map((char) => persianToEnglish[char] || char)
|
||
.join(""),
|
||
)
|
||
.join("/");
|
||
}
|
||
|
||
/**
|
||
* Get time in 24-hour format (HH:mm) from date
|
||
*/
|
||
private getTime24Hour(date: Date | string): string {
|
||
const d = new Date(date);
|
||
const hours = d.getHours().toString().padStart(2, "0");
|
||
const minutes = d.getMinutes().toString().padStart(2, "0");
|
||
return `${hours}:${minutes}`;
|
||
}
|
||
|
||
/**
|
||
* Call reverse geocoding API to get address from lat/lon
|
||
*/
|
||
private async getAddressFromCoordinates(
|
||
lat: number,
|
||
lon: number,
|
||
): Promise<string> {
|
||
try {
|
||
const apiKey =
|
||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg";
|
||
const response = await axios.get(
|
||
`https://map.ir/fast-reverse?lat=${lat}&lon=${lon}`,
|
||
{
|
||
headers: {
|
||
accept: "application/json",
|
||
"x-api-key": apiKey,
|
||
},
|
||
},
|
||
);
|
||
return response.data?.address_compact || "";
|
||
} catch (error) {
|
||
this.logger.error("Failed to get address from coordinates", error);
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate EstimateAmount from damageExpertReply parts
|
||
*/
|
||
private calculateEstimateAmount(parts: any[]): number {
|
||
if (!parts || !Array.isArray(parts) || parts.length === 0) {
|
||
return 0;
|
||
}
|
||
return parts.reduce((sum, part) => {
|
||
const totalPayment = part.totalPayment
|
||
? parseFloat(part.totalPayment.toString())
|
||
: 0;
|
||
return sum + (isNaN(totalPayment) ? 0 : totalPayment);
|
||
}, 0);
|
||
}
|
||
|
||
/** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */
|
||
private collectBranchIdsFromClaimExpertReply(reply: {
|
||
parts?: unknown[];
|
||
}): Set<string> {
|
||
const ids = new Set<string>();
|
||
const parts = reply?.parts;
|
||
if (!Array.isArray(parts)) {
|
||
return ids;
|
||
}
|
||
for (const p of parts) {
|
||
const d = (p as { daghi?: unknown })?.daghi;
|
||
if (d && typeof d === "object" && d !== null && "branchId" in d) {
|
||
const bid = (d as { branchId?: unknown }).branchId;
|
||
if (bid != null && Types.ObjectId.isValid(String(bid))) {
|
||
ids.add(String(bid));
|
||
}
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/**
|
||
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
|
||
*/
|
||
async fanavaranSubmit(claimRequestId: string): Promise<any> {
|
||
try {
|
||
// Query claim-request-management with only needed fields using aggregate for field selection
|
||
const claimRequestResult = await this.claimRequestManagementDbService.aggregate([
|
||
{ $match: { _id: new Types.ObjectId(claimRequestId) } },
|
||
{
|
||
$project: {
|
||
blameFile: 1,
|
||
damageExpertReply: 1,
|
||
damageExpertReplyFinal: 1,
|
||
},
|
||
},
|
||
]);
|
||
|
||
if (!claimRequestResult || claimRequestResult.length === 0) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
const claimRequest = claimRequestResult[0];
|
||
|
||
if (!claimRequest) {
|
||
throw new NotFoundException("Claim request not found");
|
||
}
|
||
|
||
if (!claimRequest.blameFile || !claimRequest.blameFile._id) {
|
||
throw new BadRequestException("Blame file not found in claim request");
|
||
}
|
||
|
||
const blameRequestId = claimRequest.blameFile._id.toString();
|
||
|
||
// Query request-management with only needed fields using aggregate for field selection
|
||
const blameRequestResult = await this.blameDbService.aggregate([
|
||
{ $match: { _id: new Types.ObjectId(blameRequestId) } },
|
||
{
|
||
$project: {
|
||
expertSubmitReplyFinal: 1,
|
||
expertSubmitReply: 1,
|
||
createdAt: 1,
|
||
firstPartyLocation: 1,
|
||
"firstPartyDetails.firstPartyId": 1,
|
||
"firstPartyDetails.nationalCodeOfInsurer": 1,
|
||
"secondPartyDetails.secondPartyId": 1,
|
||
"secondPartyDetails.nationalCodeOfInsurer": 1,
|
||
},
|
||
},
|
||
]);
|
||
|
||
if (!blameRequestResult || blameRequestResult.length === 0) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
const blameRequest = blameRequestResult[0];
|
||
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
// Start building the response object
|
||
const result: any = {
|
||
AccidentCityId: 701,
|
||
AccidentReportTypeId: 155,
|
||
AccidentVehicleUsedId: 1,
|
||
ClaimExpertId: 1589,
|
||
CompensationReferenceId: 167,
|
||
CulpritLicenceTypeId: 2,
|
||
CulpritTypeId: 337,
|
||
AccidentCauseId: 6, // Initialize to null, will be set if found
|
||
};
|
||
|
||
// AccidentCauseId: from expertSubmitReplyFinal.fields.accidentReason.fanavaran
|
||
// If fanavaran is 0, use id instead. First check if expertSubmitReplyFinal exists, if not check expertSubmitReply
|
||
if (blameRequest.expertSubmitReplyFinal) {
|
||
// expertSubmitReplyFinal exists, use it for AccidentCauseId
|
||
const accidentReason =
|
||
blameRequest.expertSubmitReplyFinal?.fields?.accidentReason;
|
||
const fanavaranValue = accidentReason?.fanavaran;
|
||
const idValue = accidentReason?.id;
|
||
|
||
if (fanavaranValue !== undefined && fanavaranValue !== null) {
|
||
// If fanavaran is 0, use id instead
|
||
if (fanavaranValue === 0) {
|
||
result.AccidentCauseId =
|
||
idValue !== undefined && idValue !== null ? idValue : null;
|
||
} else {
|
||
result.AccidentCauseId = fanavaranValue;
|
||
}
|
||
} else {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] expertSubmitReplyFinal exists but fanavaran is null or undefined`,
|
||
);
|
||
result.AccidentCauseId = null;
|
||
}
|
||
} else if (blameRequest.expertSubmitReply) {
|
||
// expertSubmitReplyFinal is null, use expertSubmitReply
|
||
const accidentReason =
|
||
blameRequest.expertSubmitReply?.fields?.accidentReason;
|
||
const fanavaranValue = accidentReason?.fanavaran;
|
||
const idValue = accidentReason?.id;
|
||
|
||
if (fanavaranValue !== undefined && fanavaranValue !== null) {
|
||
// If fanavaran is 0, use id instead
|
||
if (fanavaranValue === 0) {
|
||
result.AccidentCauseId =
|
||
idValue !== undefined && idValue !== null ? idValue : null;
|
||
} else {
|
||
result.AccidentCauseId = fanavaranValue;
|
||
}
|
||
} else {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] expertSubmitReply exists but fanavaran is null or undefined`,
|
||
);
|
||
result.AccidentCauseId = null;
|
||
}
|
||
} else {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Both expertSubmitReplyFinal and expertSubmitReply are null/undefined`,
|
||
);
|
||
result.AccidentCauseId = null;
|
||
}
|
||
|
||
// AccidentDate: Convert createdAt to Persian date
|
||
if (blameRequest.createdAt) {
|
||
result.AccidentDate = this.convertToPersianDate(blameRequest.createdAt);
|
||
result.AnnouncementDate = result.AccidentDate;
|
||
result.DocReceivedDate = result.AccidentDate;
|
||
}
|
||
|
||
// AccidentTime: Extract time from createdAt in 24-hour format
|
||
if (blameRequest.createdAt) {
|
||
result.AccidentTime = this.getTime24Hour(blameRequest.createdAt);
|
||
}
|
||
|
||
// AccidentLocationAddress: Get from reverse geocoding API
|
||
if (
|
||
blameRequest.firstPartyLocation?.lat &&
|
||
blameRequest.firstPartyLocation?.lon
|
||
) {
|
||
result.AccidentLocationAddress = await this.getAddressFromCoordinates(
|
||
blameRequest.firstPartyLocation.lat,
|
||
blameRequest.firstPartyLocation.lon,
|
||
);
|
||
}
|
||
|
||
// EstimateAmount: Sum of totalPayment from damageExpertReply parts
|
||
const damageReply =
|
||
claimRequest.damageExpertReplyFinal || claimRequest.damageExpertReply;
|
||
if (damageReply?.parts) {
|
||
result.EstimateAmount = this.calculateEstimateAmount(
|
||
damageReply.parts,
|
||
);
|
||
} else {
|
||
result.EstimateAmount = 0;
|
||
}
|
||
|
||
// Keep other fields from template with default values
|
||
result.AccidentCulpritId = null; //todo
|
||
result.CouponNo = null;
|
||
result.CourtArchiveNo = null;
|
||
result.CulpritLicenceCityId = null;
|
||
result.CulpritLicenceCountryId = null;
|
||
result.CulpritLicenceForeignCityName = null;
|
||
result.CulpritLicenceIssuDate = "1394/10/13"; //todo
|
||
result.CulpritLicenceNo = "1124242";
|
||
result.DamagedCount = 1;
|
||
result.DmgAssessorFirstCreationTime = null;
|
||
result.EntryDate = null;
|
||
result.IsLicenseMatchWithVehicleKind = 1;
|
||
result.HasOtherCulprit = 0;
|
||
result.IsAccidentOutOfBorder = 0;
|
||
result.IsFatalAccident = 0;
|
||
result.IsPlaqueChanged = 0;
|
||
result.PlaqueCityId = null;
|
||
result.PlaqueKindId = null;
|
||
result.PlaqueLeftNo = null;
|
||
result.PlaqueMiddleCodeId = null;
|
||
result.PlaqueNo = null;
|
||
result.PlaqueRightNo = null;
|
||
result.PlaqueSampleId = null;
|
||
result.PlaqueSerial = null;
|
||
result.PoliceOfficerId = 1;
|
||
result.PoliceReportDesc = null;
|
||
result.PoliceReportSeri = null;
|
||
result.PoliceReportSerial = null;
|
||
result.PolicyId = null;
|
||
result.PreviousPolicyEndDate = "";
|
||
result.TrackingCode = null;
|
||
result.IsLicenseReplacement = 0;
|
||
result.UnknownCulpritCauseId = null;
|
||
|
||
// Get PolicyId from external API
|
||
try {
|
||
const policyId = await this.getPolicyIdFromExternalApi(
|
||
blameRequest,
|
||
claimRequestId,
|
||
);
|
||
if (policyId) {
|
||
result.PolicyId = policyId;
|
||
}
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Failed to get PolicyId from external API:`,
|
||
error,
|
||
);
|
||
// Continue without PolicyId if API call fails
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
this.logger.error("Error in fanavaranSubmit", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 1: Get appToken from GetAppToken API
|
||
*/
|
||
private async getAppToken(): Promise<string> {
|
||
try {
|
||
const requestHeaders: any = {
|
||
appname: this.APP_NAME,
|
||
secret: this.SECRET,
|
||
"Content-Length": "0",
|
||
};
|
||
delete requestHeaders["Content-Type"];
|
||
|
||
const response = await axios({
|
||
method: "POST",
|
||
url: this.GET_APP_TOKEN_URL,
|
||
data: "",
|
||
headers: requestHeaders,
|
||
transformRequest: [
|
||
(data, headers) => {
|
||
if (headers) {
|
||
delete headers["Content-Type"];
|
||
delete headers["content-type"];
|
||
}
|
||
return data;
|
||
},
|
||
],
|
||
});
|
||
|
||
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");
|
||
throw new BadGatewayException("Failed to get appToken from response headers");
|
||
}
|
||
|
||
this.logger.log(`Successfully obtained appToken`);
|
||
return appToken;
|
||
} catch (error) {
|
||
this.logger.error("Failed to get appToken", error);
|
||
if (axios.isAxiosError(error)) {
|
||
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);
|
||
}
|
||
throw new BadGatewayException("Failed to get appToken from external API");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step 2: Login to get authenticationToken
|
||
*/
|
||
private async login(appToken: string): Promise<string> {
|
||
try {
|
||
const requestHeaders: any = {
|
||
appToken: appToken,
|
||
userName: this.USERNAME,
|
||
password: this.PASSWORD,
|
||
"Content-Length": "0",
|
||
};
|
||
delete requestHeaders["Content-Type"];
|
||
|
||
const response = await axios({
|
||
method: "POST",
|
||
url: this.LOGIN_URL,
|
||
data: "",
|
||
headers: requestHeaders,
|
||
transformRequest: [
|
||
(data, headers) => {
|
||
if (headers) {
|
||
delete headers["Content-Type"];
|
||
delete headers["content-type"];
|
||
}
|
||
return data;
|
||
},
|
||
],
|
||
});
|
||
|
||
// Check response headers first (authenticationToken is in headers)
|
||
const authenticationToken =
|
||
response.headers.authenticationtoken ||
|
||
response.headers.authenticationToken ||
|
||
response.headers["authenticationtoken"] ||
|
||
response.headers["authenticationToken"] ||
|
||
response.data?.authenticationtoken ||
|
||
response.data?.authenticationToken ||
|
||
response.data?.authentication_token;
|
||
|
||
if (!authenticationToken) {
|
||
this.logger.error("Failed to get authenticationToken from response");
|
||
throw new BadGatewayException("Failed to get authenticationToken from response");
|
||
}
|
||
|
||
this.logger.log(`Successfully obtained authenticationToken`);
|
||
return authenticationToken;
|
||
} catch (error) {
|
||
this.logger.error("Failed to login", error);
|
||
if (axios.isAxiosError(error)) {
|
||
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);
|
||
}
|
||
throw new BadGatewayException("Failed to login to external API");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get PolicyId from external API
|
||
*/
|
||
private async getPolicyIdFromExternalApi(
|
||
blameRequest: any,
|
||
claimRequestId: string,
|
||
): Promise<number | null> {
|
||
try {
|
||
// Get guiltyUserId from expertSubmitReplyFinal or expertSubmitReply
|
||
let guiltyUserId: string | null = null;
|
||
if (blameRequest.expertSubmitReplyFinal?.guiltyUserId) {
|
||
guiltyUserId = blameRequest.expertSubmitReplyFinal.guiltyUserId;
|
||
} else if (blameRequest.expertSubmitReply?.guiltyUserId) {
|
||
guiltyUserId = blameRequest.expertSubmitReply.guiltyUserId;
|
||
}
|
||
|
||
if (!guiltyUserId) {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] guiltyUserId not found in expertSubmitReplyFinal or expertSubmitReply`,
|
||
);
|
||
return null;
|
||
}
|
||
|
||
// Find the nationalCodeOfInsurer by matching guiltyUserId with firstPartyId or secondPartyId
|
||
let nationalCodeOfInsurer: string | null = null;
|
||
|
||
if (
|
||
blameRequest.firstPartyDetails?.firstPartyId?.toString() ===
|
||
guiltyUserId.toString()
|
||
) {
|
||
nationalCodeOfInsurer =
|
||
blameRequest.firstPartyDetails?.nationalCodeOfInsurer;
|
||
} else if (
|
||
blameRequest.secondPartyDetails?.secondPartyId?.toString() ===
|
||
guiltyUserId.toString()
|
||
) {
|
||
nationalCodeOfInsurer =
|
||
blameRequest.secondPartyDetails?.nationalCodeOfInsurer;
|
||
}
|
||
|
||
if (!nationalCodeOfInsurer) {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] nationalCodeOfInsurer not found for guiltyUserId: ${guiltyUserId}`,
|
||
);
|
||
return null;
|
||
}
|
||
|
||
// Get authenticationToken
|
||
const appToken = await this.getAppToken();
|
||
const authenticationToken = await this.login(appToken);
|
||
|
||
// Call external API to get PolicyId
|
||
const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
|
||
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
|
||
);
|
||
|
||
const response = await axios.get(policyInquiryUrl, {
|
||
headers: {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: this.CORP_ID,
|
||
ContractId: this.CONTRACT_ID,
|
||
Location: this.LOCATION,
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
|
||
if (
|
||
Array.isArray(response.data) &&
|
||
response.data.length > 0 &&
|
||
response.data[0].PolicyId
|
||
) {
|
||
const policyId = response.data[0].PolicyId;
|
||
this.logger.log(
|
||
`[Fanavaran Submit] Successfully retrieved PolicyId: ${policyId}`,
|
||
);
|
||
return policyId;
|
||
} else {
|
||
this.logger.warn(
|
||
`[Fanavaran Submit] No PolicyId found in API response`,
|
||
);
|
||
return null;
|
||
}
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`[Fanavaran Submit] Error getting PolicyId from external API:`,
|
||
error,
|
||
);
|
||
if (axios.isAxiosError(error)) {
|
||
const errorMessage =
|
||
error.response?.data?.Message ||
|
||
error.response?.data?.message ||
|
||
error.response?.data ||
|
||
error.message ||
|
||
"Failed to get PolicyId from external API";
|
||
this.logger.error(`[Fanavaran Submit] Error message: ${errorMessage}`);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Submit fanavaran data to external API
|
||
*/
|
||
async submitToFanavaran(claimRequestId: string): Promise<any> {
|
||
try {
|
||
// First, get the mapped data
|
||
this.logger.log(`[Fanavaran Submit] Starting submission for claimRequestId: ${claimRequestId}`);
|
||
const fanavaranData = await this.fanavaranSubmit(claimRequestId);
|
||
this.logger.log(`[Fanavaran Submit] Mapped data prepared:`, JSON.stringify(fanavaranData, null, 2));
|
||
|
||
// Validate required fields before submission
|
||
if (fanavaranData.AccidentCauseId === undefined || fanavaranData.AccidentCauseId === null) {
|
||
this.logger.error(`[Fanavaran Submit] CRITICAL: AccidentCauseId is missing or null!`);
|
||
this.logger.error(`[Fanavaran Submit] This field is required by the API. Request will likely fail.`);
|
||
this.logger.error(`[Fanavaran Submit] Available fields in result:`, Object.keys(fanavaranData));
|
||
} else {
|
||
this.logger.log(`[Fanavaran Submit] AccidentCauseId validated: ${fanavaranData.AccidentCauseId}`);
|
||
}
|
||
|
||
// Step 1: Get appToken
|
||
this.logger.log(`[Fanavaran Submit] Step 1: Getting appToken`);
|
||
const appToken = await this.getAppToken();
|
||
this.logger.log(`[Fanavaran Submit] AppToken obtained: ${appToken}`);
|
||
|
||
// Step 2: Login to get authenticationToken
|
||
this.logger.log(`[Fanavaran Submit] Step 2: Logging in to get authenticationToken`);
|
||
const authenticationToken = await this.login(appToken);
|
||
this.logger.log(`[Fanavaran Submit] AuthenticationToken obtained: ${authenticationToken}`);
|
||
|
||
// Step 3: Submit data to Fanavaran API
|
||
this.logger.log(`[Fanavaran Submit] Step 3: Submitting data to Fanavaran API`);
|
||
this.logger.log(`[Fanavaran Submit] URL: ${this.FANAVARAN_SUBMIT_URL}`);
|
||
this.logger.log(`[Fanavaran Submit] Request headers:`, {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: this.CORP_ID,
|
||
ContractId: this.CONTRACT_ID,
|
||
Location: this.LOCATION,
|
||
"Content-Type": "application/json",
|
||
});
|
||
this.logger.log(`[Fanavaran Submit] Request body:`, JSON.stringify(fanavaranData, null, 2));
|
||
|
||
const response = await axios.post(
|
||
this.FANAVARAN_SUBMIT_URL,
|
||
fanavaranData,
|
||
{
|
||
headers: {
|
||
authenticationToken: authenticationToken,
|
||
CorpId: this.CORP_ID,
|
||
ContractId: this.CONTRACT_ID,
|
||
Location: this.LOCATION,
|
||
"Content-Type": "application/json",
|
||
},
|
||
},
|
||
);
|
||
|
||
this.logger.log(`[Fanavaran Submit] API Response Status: ${response.status}`);
|
||
this.logger.log(`[Fanavaran Submit] API Response Headers:`, JSON.stringify(response.headers, null, 2));
|
||
this.logger.log(`[Fanavaran Submit] API Response Data:`, JSON.stringify(response.data, null, 2));
|
||
this.logger.log("Successfully submitted data to Fanavaran API");
|
||
|
||
// Step 4: Save ClaimNo and Id from response to claim-request-management document
|
||
if (response.data) {
|
||
const claimNo = response.data.ClaimNo;
|
||
const claimId = response.data.Id;
|
||
|
||
this.logger.log(`[Fanavaran Submit] Extracted from response - ClaimNo: ${claimNo}, Id: ${claimId}`);
|
||
|
||
if (claimNo !== undefined || claimId !== undefined) {
|
||
const updateData: any = {
|
||
$set: {},
|
||
};
|
||
if (claimNo !== undefined) {
|
||
updateData.$set.claimNo = claimNo;
|
||
}
|
||
if (claimId !== undefined) {
|
||
updateData.$set.claimId = claimId;
|
||
}
|
||
|
||
this.logger.log(`[Fanavaran Submit] Updating document with:`, JSON.stringify(updateData, null, 2));
|
||
await this.claimRequestManagementDbService.findAndUpdate(
|
||
claimRequestId,
|
||
updateData,
|
||
);
|
||
|
||
this.logger.log(
|
||
`Updated claim-request-management document with claimNo: ${claimNo}, claimId: ${claimId}`,
|
||
);
|
||
} else {
|
||
this.logger.warn(`[Fanavaran Submit] No ClaimNo or Id found in response data`);
|
||
}
|
||
} else {
|
||
this.logger.warn(`[Fanavaran Submit] No response data received`);
|
||
}
|
||
|
||
return response.data;
|
||
} catch (error) {
|
||
this.logger.error("[Fanavaran Submit] Error submitting to Fanavaran");
|
||
this.logger.error("[Fanavaran Submit] Error type:", error?.constructor?.name);
|
||
|
||
if (axios.isAxiosError(error)) {
|
||
this.logger.error("[Fanavaran Submit] Axios Error Details:");
|
||
this.logger.error("[Fanavaran Submit] Error Status:", error.response?.status);
|
||
this.logger.error("[Fanavaran Submit] Error Status Text:", error.response?.statusText);
|
||
this.logger.error("[Fanavaran Submit] Error Response Headers:", JSON.stringify(error.response?.headers, null, 2));
|
||
this.logger.error("[Fanavaran Submit] Error Response Data:", JSON.stringify(error.response?.data, null, 2));
|
||
this.logger.error("[Fanavaran Submit] Request URL:", error.config?.url);
|
||
this.logger.error("[Fanavaran Submit] Request Method:", error.config?.method);
|
||
this.logger.error("[Fanavaran Submit] Request Headers:", JSON.stringify(error.config?.headers, null, 2));
|
||
this.logger.error("[Fanavaran Submit] Request Data:", JSON.stringify(error.config?.data, null, 2));
|
||
|
||
const errorMessage = error.response?.data?.message ||
|
||
error.response?.data?.Message ||
|
||
error.response?.data ||
|
||
error.message ||
|
||
"Failed to submit data to Fanavaran API";
|
||
|
||
this.logger.error("[Fanavaran Submit] Extracted Error Message:", errorMessage);
|
||
throw new BadGatewayException(errorMessage);
|
||
} else {
|
||
this.logger.error("[Fanavaran Submit] Non-Axios Error:", error);
|
||
throw new BadGatewayException("Failed to submit data to Fanavaran API");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Create claim request from completed blame case
|
||
* Only damaged party can create claim after both parties accept expert decision
|
||
*/
|
||
async createClaimFromBlameV2(
|
||
blameRequestId: string,
|
||
currentUserId: string,
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
try {
|
||
// 1. Fetch blame request from new blameCases collection
|
||
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
|
||
// 2. Validate blame status is COMPLETED
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
|
||
const parties = blameRequest.parties || [];
|
||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||
|
||
if (isCarBody) {
|
||
// CAR_BODY: single party, no expert; first party is the damaged one who creates the claim
|
||
if (parties.length < 1) {
|
||
throw new BadRequestException("Blame request has no party");
|
||
}
|
||
const firstParty = parties.find((p) => p.role === "FIRST");
|
||
if (!firstParty || String(firstParty.person?.userId) !== currentUserId) {
|
||
throw new ForbiddenException(
|
||
"Only the first party (damaged) can create a claim from this CAR_BODY blame request",
|
||
);
|
||
}
|
||
} else {
|
||
// THIRD_PARTY: require expert decision and both parties signed
|
||
if (!blameRequest.expert?.decision) {
|
||
throw new BadRequestException(
|
||
"Cannot create claim until expert has made a decision",
|
||
);
|
||
}
|
||
|
||
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
|
||
|
||
if (parties.length < 2) {
|
||
throw new BadRequestException(
|
||
"Both parties must be present in the blame request",
|
||
);
|
||
}
|
||
|
||
const allPartiesSigned = parties.every(
|
||
(p) => p.confirmation !== undefined && p.confirmation !== null,
|
||
);
|
||
|
||
if (!allPartiesSigned) {
|
||
throw new BadRequestException(
|
||
"Both parties must sign the expert decision before creating a claim",
|
||
);
|
||
}
|
||
|
||
const allPartiesAccepted = parties.every(
|
||
(p) => p.confirmation?.accepted === true,
|
||
);
|
||
|
||
if (!allPartiesAccepted) {
|
||
throw new BadRequestException(
|
||
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
|
||
);
|
||
}
|
||
|
||
const guiltyParty = parties.find((p) => {
|
||
return String(p.person?.userId) === guiltyPartyId;
|
||
});
|
||
|
||
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
|
||
throw new ForbiddenException(
|
||
"You cannot create a claim as you are the guilty party",
|
||
);
|
||
}
|
||
}
|
||
|
||
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
|
||
const currentUserParty = parties.find(
|
||
(p) => String(p.person?.userId) === currentUserId,
|
||
);
|
||
|
||
if (!currentUserParty) {
|
||
throw new ForbiddenException(
|
||
"You are not a party in this blame request",
|
||
);
|
||
}
|
||
|
||
// 6. Validate no existing claim for this blame
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
|
||
if (existingClaim) {
|
||
throw new ConflictException(
|
||
"A claim request for this blame case already exists",
|
||
);
|
||
}
|
||
|
||
// 7. Generate claim number
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
|
||
// 8. Create claim case
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
owner: {
|
||
userId: new Types.ObjectId(currentUserId),
|
||
userRole: currentUserParty.role as any,
|
||
...(currentUserParty.person?.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(
|
||
currentUserParty.person.clientId as any,
|
||
),
|
||
}
|
||
: {}),
|
||
},
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: currentUserParty.person?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
|
||
this.logger.log(
|
||
`Claim created: ${newClaim._id} from blame: ${blameRequestId}`,
|
||
);
|
||
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message: "Claim request created successfully",
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
"createClaimFromBlameV2 failed",
|
||
blameRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Failed to create claim request",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
|
||
* The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY).
|
||
*/
|
||
async createClaimFromBlameForExpertV2(
|
||
blameRequestId: string,
|
||
expert: { sub: string; firstName?: string; lastName?: string },
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) {
|
||
throw new NotFoundException("Blame request not found");
|
||
}
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||
!blameRequest.initiatedByFieldExpertId
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the field expert who created this blame file can create the claim.",
|
||
);
|
||
}
|
||
const parties = blameRequest.parties || [];
|
||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||
let damagedUserId: string;
|
||
if (isCarBody) {
|
||
if (parties.length < 1) throw new BadRequestException("Blame request has no party");
|
||
const first = parties.find((p) => p.role === "FIRST");
|
||
if (!first?.person?.userId) throw new BadRequestException("First party has no userId");
|
||
damagedUserId = String(first.person.userId);
|
||
} else {
|
||
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
|
||
? String(blameRequest.expert.decision.guiltyPartyId)
|
||
: null;
|
||
if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set");
|
||
const damagedParty = parties.find(
|
||
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
|
||
);
|
||
if (!damagedParty?.person?.userId) {
|
||
throw new BadRequestException("Could not determine damaged party");
|
||
}
|
||
damagedUserId = String(damagedParty.person.userId);
|
||
}
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
if (existingClaim) {
|
||
throw new ConflictException("A claim for this blame case already exists");
|
||
}
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
const damagedParty = parties.find(
|
||
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
|
||
);
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
owner: {
|
||
userId: new Types.ObjectId(damagedUserId),
|
||
userRole: damagedParty?.role as any,
|
||
...(damagedParty?.person?.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
|
||
}
|
||
: {}),
|
||
},
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(expert.sub),
|
||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||
actorType: "field_expert",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
createdByExpert: true,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
this.logger.log(
|
||
`Claim created by field expert: ${newClaim._id} from blame: ${blameRequestId}`,
|
||
);
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message: "Claim created successfully. You can now fill claim data on behalf of the damaged party.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve effective user id for claim operations.
|
||
* For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId.
|
||
*/
|
||
private async resolveClaimEffectiveUserId(
|
||
claimCase: any,
|
||
currentUserId: string,
|
||
actorRole?: string,
|
||
): Promise<string> {
|
||
if (
|
||
![RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR].includes(actorRole as any) ||
|
||
!claimCase?.blameRequestId
|
||
) {
|
||
return currentUserId;
|
||
}
|
||
const blameRequest = await this.blameRequestDbService.findById(
|
||
claimCase.blameRequestId.toString(),
|
||
);
|
||
if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON") return currentUserId;
|
||
if (actorRole === RoleEnum.FIELD_EXPERT) {
|
||
if (
|
||
!blameRequest.expertInitiated ||
|
||
!blameRequest.initiatedByFieldExpertId ||
|
||
String(blameRequest.initiatedByFieldExpertId) !== currentUserId
|
||
) {
|
||
return currentUserId;
|
||
}
|
||
} else {
|
||
if (
|
||
!blameRequest.registrarInitiated ||
|
||
!blameRequest.initiatedByRegistrarId ||
|
||
String(blameRequest.initiatedByRegistrarId) !== currentUserId
|
||
) {
|
||
return currentUserId;
|
||
}
|
||
}
|
||
return claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId;
|
||
}
|
||
|
||
async createClaimFromBlameForRegistrarV1(
|
||
blameRequestId: string,
|
||
registrar: { sub: string; firstName?: string; lastName?: string },
|
||
): Promise<CreateClaimFromBlameResponseDto> {
|
||
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||
if (!blameRequest) throw new NotFoundException("Blame request not found");
|
||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||
throw new BadRequestException(
|
||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||
);
|
||
}
|
||
if (
|
||
!blameRequest.registrarInitiated ||
|
||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||
!blameRequest.initiatedByRegistrarId
|
||
) {
|
||
throw new BadRequestException(
|
||
"This endpoint is only for registrar-initiated IN_PERSON blame files.",
|
||
);
|
||
}
|
||
if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) {
|
||
throw new ForbiddenException(
|
||
"Only the registrar who created this blame file can create the claim.",
|
||
);
|
||
}
|
||
const parties = blameRequest.parties || [];
|
||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||
let damagedUserId: string;
|
||
if (isCarBody) {
|
||
const first = parties.find((p) => p.role === "FIRST");
|
||
if (!first?.person?.userId) throw new BadRequestException("First party has no userId");
|
||
damagedUserId = String(first.person.userId);
|
||
} else {
|
||
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
|
||
? String(blameRequest.expert.decision.guiltyPartyId)
|
||
: null;
|
||
if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set");
|
||
const damagedParty = parties.find(
|
||
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
|
||
);
|
||
if (!damagedParty?.person?.userId) throw new BadRequestException("Could not determine damaged party");
|
||
damagedUserId = String(damagedParty.person.userId);
|
||
}
|
||
const existingClaim = await this.claimCaseDbService.findOne({
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
});
|
||
if (existingClaim) throw new ConflictException("A claim for this blame case already exists");
|
||
const claimNo = await this.generateUniqueClaimNumber();
|
||
const damagedParty = parties.find(
|
||
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
|
||
);
|
||
const newClaim = await this.claimCaseDbService.create({
|
||
requestNo: claimNo,
|
||
publicId: blameRequest.publicId,
|
||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||
blameRequestNo: blameRequest.requestNo,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
workflow: {
|
||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||
locked: false,
|
||
},
|
||
owner: {
|
||
userId: new Types.ObjectId(damagedUserId),
|
||
userRole: damagedParty?.role as any,
|
||
...(damagedParty?.person?.clientId
|
||
? {
|
||
clientId: new Types.ObjectId(damagedParty.person.clientId as any),
|
||
}
|
||
: {}),
|
||
},
|
||
createdByRegistrarId: new Types.ObjectId(registrar.sub),
|
||
history: [
|
||
{
|
||
type: "CLAIM_CREATED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(registrar.sub),
|
||
actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
|
||
actorType: "registrar",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
blameRequestId,
|
||
blamePublicId: blameRequest.publicId,
|
||
createdByRegistrar: true,
|
||
},
|
||
},
|
||
],
|
||
} as any);
|
||
return {
|
||
claimRequestId: String(newClaim._id),
|
||
publicId: blameRequest.publicId,
|
||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||
message: "Claim created successfully. Registrar can now fill claim data.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
|
||
*
|
||
* @param claimRequestId - The claim case ID
|
||
* @param body - Selected outer parts
|
||
* @param currentUserId - The current user's ID
|
||
* @returns Response with updated claim information
|
||
*
|
||
* Validations:
|
||
* - Claim case must exist
|
||
* - User must be the claim owner (damaged party)
|
||
* - Current workflow step must be CLAIM_CREATED (step 1)
|
||
* - Selected parts array must not be empty
|
||
* - Parts must not have been selected previously
|
||
*/
|
||
async selectOuterPartsV2(
|
||
claimRequestId: string,
|
||
body: SelectOuterPartsV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||
try {
|
||
// 1. Validate claim exists
|
||
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,
|
||
);
|
||
// 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim)
|
||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
'Only the claim owner (damaged party) can select damaged parts'
|
||
);
|
||
}
|
||
|
||
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
|
||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OUTER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
|
||
);
|
||
}
|
||
|
||
// 4. Validate outer parts haven't been selected yet
|
||
if (claimCase.damage?.selectedParts && claimCase.damage.selectedParts.length > 0) {
|
||
throw new ConflictException(
|
||
'Outer parts have already been selected for this claim'
|
||
);
|
||
}
|
||
|
||
// 5. Validate by selected car type and resolve selected parts by ids/keys
|
||
const selectedCarType = (body as any)?.carType as
|
||
| ClaimVehicleTypeV2
|
||
| undefined;
|
||
if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) {
|
||
throw new BadRequestException(
|
||
"Vehicle type is required before selecting outer parts.",
|
||
);
|
||
}
|
||
|
||
const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType];
|
||
const byId = new Map<number, OuterPartCatalogItem>(catalog.map((p) => [p.id, p]));
|
||
const byKey = new Map<string, OuterPartCatalogItem>(catalog.map((p) => [p.key, p]));
|
||
|
||
const selectedFromIds: OuterPartCatalogItem[] = [];
|
||
if (
|
||
Array.isArray((body as any)?.selectedPartIds) &&
|
||
(body as any).selectedPartIds.length > 0
|
||
) {
|
||
for (const id of (body as any).selectedPartIds as number[]) {
|
||
const item = byId.get(id);
|
||
if (!item) {
|
||
throw new BadRequestException(
|
||
`Invalid outer part id for ${selectedCarType}: ${id}`,
|
||
);
|
||
}
|
||
selectedFromIds.push(item);
|
||
}
|
||
}
|
||
|
||
// Backward compatibility with selectedParts + legacy carPartDamage
|
||
let selectedParts = Array.isArray((body as any)?.selectedParts)
|
||
? ((body as any).selectedParts as string[])
|
||
: [];
|
||
if (selectedParts.length === 0 && (body as any)?.carPartDamage) {
|
||
this.assertCarPartDamageAtMostTwoOfFourSides(
|
||
(body as any).carPartDamage as CarDamagePartDto,
|
||
);
|
||
selectedParts = this.carDamagePartDtoToOuterPartSlugs(
|
||
(body as any).carPartDamage as CarDamagePartDto,
|
||
);
|
||
}
|
||
const selectedFromKeys: OuterPartCatalogItem[] = [];
|
||
for (const key of selectedParts) {
|
||
const item = byKey.get(key);
|
||
if (!item) {
|
||
throw new BadRequestException(
|
||
`Invalid outer part key for ${selectedCarType}: ${key}`,
|
||
);
|
||
}
|
||
selectedFromKeys.push(item);
|
||
}
|
||
|
||
const selectedItems =
|
||
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
|
||
if (selectedItems.length === 0) {
|
||
throw new BadRequestException(
|
||
"selectedPartIds or selectedParts is required and must contain at least one item",
|
||
);
|
||
}
|
||
|
||
// At most two non-top sides allowed
|
||
const sideSet = new Set(
|
||
selectedItems
|
||
.map((p) => p.side)
|
||
.filter((s) => s !== "top"),
|
||
);
|
||
if (sideSet.size > 2) {
|
||
throw new BadRequestException(
|
||
`At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`,
|
||
);
|
||
}
|
||
|
||
selectedParts = selectedItems.map((p) => p.key);
|
||
const selectedPartIds = selectedItems.map((p) => p.id);
|
||
const selectedOuterParts = selectedItems.map((p) => ({
|
||
id: p.id,
|
||
key: p.key,
|
||
side: p.side,
|
||
}));
|
||
|
||
// 6. Update claim case with selected parts and move to next step
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
{
|
||
'damage.selectedParts': selectedParts,
|
||
'damage.selectedOuterParts': selectedOuterParts,
|
||
'vehicle.carType': selectedCarType,
|
||
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
$push: {
|
||
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
history: {
|
||
type: 'STEP_COMPLETED',
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || 'User',
|
||
actorType: 'user',
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||
selectedParts: selectedParts,
|
||
selectedPartIds,
|
||
carType: selectedCarType,
|
||
partsCount: selectedParts?.length,
|
||
description: `User selected ${selectedParts?.length} damaged outer parts`,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new InternalServerErrorException('Failed to update claim case');
|
||
}
|
||
|
||
this.logger.log(
|
||
`Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`,
|
||
);
|
||
|
||
// 7. Return response
|
||
return {
|
||
claimRequestId: updatedClaim._id.toString(),
|
||
publicId: updatedClaim.publicId,
|
||
selectedParts: selectedParts,
|
||
selectedPartIds,
|
||
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
'selectOuterPartsV2 failed',
|
||
claimRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to select outer parts',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow)
|
||
*
|
||
* @param claimRequestId - The claim case ID
|
||
* @param body - Other parts, Sheba number, and national code
|
||
* @param currentUserId - The current user's ID
|
||
* @returns Response with updated claim information
|
||
*
|
||
* Validations:
|
||
* - Claim case must exist
|
||
* - User must be the claim owner (damaged party)
|
||
* - Current workflow step must be SELECT_OTHER_PARTS (step 3)
|
||
* - Other parts and bank info must not have been submitted previously
|
||
*/
|
||
async selectOtherPartsV2(
|
||
claimRequestId: string,
|
||
body: SelectOtherPartsV2Dto,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
file?: Express.Multer.File,
|
||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||
try {
|
||
// 1. Validate claim exists
|
||
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,
|
||
);
|
||
// 2. Validate user is the claim owner (or field expert for IN_PERSON claim)
|
||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||
throw new ForbiddenException(
|
||
'Only the claim owner (damaged party) can submit other parts and bank information'
|
||
);
|
||
}
|
||
|
||
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
|
||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
|
||
);
|
||
}
|
||
|
||
// 4. Validate other parts and bank info haven't been submitted yet
|
||
if (claimCase.money?.sheba || claimCase.money?.nationalCodeOfInsurer) {
|
||
throw new ConflictException(
|
||
'Bank information has already been submitted for this claim'
|
||
);
|
||
}
|
||
|
||
// 5. Prepare data
|
||
// Backward compatibility aliases:
|
||
// - shebaNumber <- sheba
|
||
// - nationalCodeOfOwner <- nationalCodeOfInsurer
|
||
let otherParts = Array.isArray((body as any)?.otherParts)
|
||
? (body as any).otherParts
|
||
: [];
|
||
// Multipart clients may send otherParts as JSON string
|
||
if (!Array.isArray((body as any)?.otherParts) && typeof (body as any)?.otherParts === "string") {
|
||
try {
|
||
const parsed = JSON.parse((body as any).otherParts);
|
||
otherParts = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
otherParts = [];
|
||
}
|
||
}
|
||
const shebaInput = String(
|
||
((body as any).shebaNumber ?? (body as any).sheba ?? "") as string,
|
||
)
|
||
.replace(/\s/g, "");
|
||
|
||
const shebaDigits = shebaInput.replace(/^IR/i, "");
|
||
const shebaNumber = `IR${shebaDigits}`;
|
||
const nationalCode = String(
|
||
((body as any).nationalCodeOfOwner ??
|
||
(body as any).nationalCodeOfInsurer ??
|
||
"") as string,
|
||
).replace(/\s/g, "");
|
||
if (!/^[0-9]{24}$/.test(shebaDigits)) {
|
||
throw new BadRequestException(
|
||
"shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)",
|
||
);
|
||
}
|
||
if (!/^[0-9]{10}$/.test(nationalCode)) {
|
||
throw new BadRequestException(
|
||
"nationalCodeOfOwner is required and must be exactly 10 digits",
|
||
);
|
||
}
|
||
|
||
const updatePayload: any = {
|
||
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
|
||
'money.sheba': shebaNumber,
|
||
'money.nationalCodeOfInsurer': nationalCode,
|
||
'status': ClaimWorkflowStep.CAPTURE_PART_DAMAGES ,
|
||
'workflow.currentStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
'workflow.nextStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
$push: {
|
||
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
history: {
|
||
type: 'STEP_COMPLETED',
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || 'User',
|
||
actorType: 'user',
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
otherParts: otherParts,
|
||
otherPartsCount: otherParts.length,
|
||
hasBankInfo: true,
|
||
hasGreenCardUpload: !!file,
|
||
description: `User selected ${otherParts.length} other damaged parts and provided bank information`,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
if (file) {
|
||
const existingGreen =
|
||
(claimCase.requiredDocuments as any)?.get?.(
|
||
ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||
) ||
|
||
(claimCase.requiredDocuments as any)?.[
|
||
ClaimRequiredDocumentType.CAR_GREEN_CARD
|
||
];
|
||
if (existingGreen?.uploaded) {
|
||
throw new ConflictException(
|
||
"car_green_card has already been uploaded for this claim",
|
||
);
|
||
}
|
||
const docRef = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||
uploadedAt: new Date(),
|
||
});
|
||
updatePayload[
|
||
`requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}`
|
||
] = {
|
||
fileId: docRef._id,
|
||
filePath: file.path,
|
||
fileName: file.filename,
|
||
uploaded: true,
|
||
uploadedAt: new Date(),
|
||
};
|
||
}
|
||
|
||
// 6. Update claim case with other parts, bank info and optional green card file
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updatePayload,
|
||
);
|
||
|
||
if (!updatedClaim) {
|
||
throw new InternalServerErrorException('Failed to update claim case');
|
||
}
|
||
|
||
this.logger.log(
|
||
`Other parts and bank info submitted for claim ${claimRequestId}: ${otherParts.length} parts`,
|
||
);
|
||
|
||
// 7. Mask sensitive data for response
|
||
const maskedSheba = `IR${shebaDigits.slice(0, 4)}************${shebaDigits.slice(-4)}`;
|
||
const maskedNationalCode = `${nationalCode.slice(0, 2)}******${nationalCode.slice(-2)}`;
|
||
|
||
// 8. Return response
|
||
return {
|
||
claimRequestId: updatedClaim._id.toString(),
|
||
publicId: updatedClaim.publicId,
|
||
otherParts: otherParts,
|
||
shebaNumber: maskedSheba,
|
||
nationalCodeOfOwner: maskedNationalCode,
|
||
currentStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
message:
|
||
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error(
|
||
'selectOtherPartsV2 failed',
|
||
claimRequestId,
|
||
error instanceof Error ? error.stack : String(error),
|
||
);
|
||
throw new InternalServerErrorException(
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to select other parts',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get capture requirements for claim (documents, angles, parts)
|
||
*/
|
||
async getCaptureRequirementsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||
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 view capture requirements');
|
||
}
|
||
|
||
// Build car-type aware outer-parts lookup (complete source: static catalog)
|
||
const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||
const catalogForType =
|
||
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||
: this.getOuterPartsCatalogV2();
|
||
const catalogByKey = new Map<string, OuterPartCatalogItem>();
|
||
for (const item of catalogForType) {
|
||
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
||
}
|
||
|
||
const getPartLabel = (key: string): { fa: string; en: string } => {
|
||
const catalogItem = catalogByKey.get(key);
|
||
if (catalogItem) {
|
||
const en = key
|
||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||
.replace(/_/g, " ")
|
||
.trim()
|
||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||
return { fa: catalogItem.titleFa, en };
|
||
}
|
||
return { fa: key, en: key };
|
||
};
|
||
|
||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
||
const blameRequest = claimCase.blameRequestId
|
||
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
|
||
: null;
|
||
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
|
||
const requiredDocsDefinition = [
|
||
{ key: 'damaged_driving_license_front', label_fa: 'گواهینامه طرف آسیبدیده - جلو', label_en: 'Damaged Party License - Front', category: 'damaged_party' },
|
||
{ key: 'damaged_driving_license_back', label_fa: 'گواهینامه طرف آسیبدیده - پشت', label_en: 'Damaged Party License - Back', category: 'damaged_party' },
|
||
{ key: 'damaged_chassis_number', label_fa: 'شماره شاسی', label_en: 'Chassis Number', category: 'damaged_party' },
|
||
{ key: 'damaged_engine_photo', label_fa: 'عکس موتور', label_en: 'Engine Photo', category: 'damaged_party' },
|
||
{ key: 'damaged_car_card_front', label_fa: 'کارت خودرو آسیبدیده - جلو', label_en: 'Damaged Car Card - Front', category: 'damaged_party' },
|
||
{ key: 'damaged_car_card_back', label_fa: 'کارت خودرو آسیبدیده - پشت', label_en: 'Damaged Car Card - Back', category: 'damaged_party' },
|
||
{ key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیبدیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
|
||
...(isCarBody ? [] : [
|
||
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
|
||
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
|
||
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
|
||
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
|
||
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
|
||
]),
|
||
];
|
||
|
||
const requiredDocuments = requiredDocsDefinition.map(doc => {
|
||
const docData = (claimCase.requiredDocuments as any)?.get?.(doc.key);
|
||
return {
|
||
key: doc.key,
|
||
label_fa: doc.label_fa,
|
||
label_en: doc.label_en,
|
||
category: doc.category,
|
||
uploaded: docData?.uploaded || false,
|
||
};
|
||
});
|
||
|
||
const hasCapture = (data: any, key: string) =>
|
||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||
|
||
// Car angles
|
||
const carAngles = [
|
||
{ key: 'front', label_fa: 'جلو', label_en: 'Front' },
|
||
{ key: 'back', label_fa: 'عقب', label_en: 'Back' },
|
||
{ key: 'left', label_fa: 'چپ', label_en: 'Left' },
|
||
{ key: 'right', label_fa: 'راست', label_en: 'Right' },
|
||
].map(angle => ({
|
||
key: angle.key,
|
||
label_fa: angle.label_fa,
|
||
label_en: angle.label_en,
|
||
captured: hasClaimCarAngleCapture(
|
||
claimCase.media?.carAngles as any,
|
||
claimCase.media?.damagedParts as any,
|
||
angle.key as ClaimCarAngleKey,
|
||
),
|
||
}));
|
||
|
||
// Damaged parts from selected parts
|
||
const selectedParts = claimCase.damage?.selectedParts || [];
|
||
const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts)
|
||
? claimCase.damage.selectedOuterParts
|
||
: [];
|
||
const selectedOuterPartByKey = new Map<
|
||
string,
|
||
{ id?: number; side?: string }
|
||
>(
|
||
selectedOuterParts.map((p) => [
|
||
p.key,
|
||
{
|
||
id: p.id,
|
||
side: p.side,
|
||
},
|
||
]),
|
||
);
|
||
|
||
const damagedParts = selectedParts.map(partKey => {
|
||
const label = getPartLabel(partKey);
|
||
const selectedMeta = selectedOuterPartByKey.get(partKey);
|
||
const catalogMeta = catalogByKey.get(partKey);
|
||
const side = selectedMeta?.side ?? catalogMeta?.side;
|
||
const sideFaMap: Record<string, string> = {
|
||
left: "چپ",
|
||
right: "راست",
|
||
front: "جلو",
|
||
back: "عقب",
|
||
top: "بالا",
|
||
};
|
||
const sideFa = side ? sideFaMap[side] : undefined;
|
||
return {
|
||
id: selectedMeta?.id ?? catalogMeta?.id,
|
||
key: partKey,
|
||
side,
|
||
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa,
|
||
label_en: label.en,
|
||
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
|
||
};
|
||
});
|
||
|
||
// Calculate progress
|
||
const documentsUploaded = requiredDocuments.filter(d => d.uploaded).length;
|
||
const anglesCaptured = carAngles.filter(a => a.captured).length;
|
||
const partsCaptured = damagedParts.filter(p => p.captured).length;
|
||
|
||
const totalRemaining =
|
||
(requiredDocuments.length - documentsUploaded) +
|
||
(carAngles.length - anglesCaptured) +
|
||
(damagedParts.length - partsCaptured);
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
publicId: claimCase.publicId,
|
||
currentStep: claimCase.workflow?.currentStep || '',
|
||
requiredDocuments,
|
||
carAngles,
|
||
damagedParts,
|
||
totalRemaining,
|
||
progress: {
|
||
documentsUploaded,
|
||
documentsTotal: requiredDocuments.length,
|
||
anglesCaptured,
|
||
anglesTotal: carAngles.length,
|
||
partsCaptured,
|
||
partsTotal: damagedParts.length,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error('getCaptureRequirementsV2 failed', error);
|
||
throw new InternalServerErrorException('Failed to get capture requirements');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* When the owner has uploaded every expert-requested document/part (or acknowledged description-only resend),
|
||
* move the claim back to the damage expert queue (same posture as after initial submission).
|
||
*/
|
||
private async tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId: string,
|
||
actorUserId: string,
|
||
ownerDisplayName: string,
|
||
): Promise<void> {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim || !canFinalizeExpertResend(claim)) {
|
||
return;
|
||
}
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||
claimStatus: ClaimStatus.PENDING,
|
||
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||
"evaluation.damageExpertResend.fulfilledAt": new Date(),
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "EXPERT_RESEND_FULFILLED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(actorUserId),
|
||
actorName: ownerDisplayName,
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* V2: Owner acknowledges a description-only expert resend (no extra documents or part photos requested).
|
||
*/
|
||
async acknowledgeExpertResendInstructionsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<{
|
||
claimRequestId: string;
|
||
status: string;
|
||
claimStatus: string;
|
||
currentStep: string;
|
||
message: string;
|
||
}> {
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||
}
|
||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||
claimCase,
|
||
currentUserId,
|
||
actor?.role,
|
||
);
|
||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||
throw new ForbiddenException("Only the claim owner can complete this step");
|
||
}
|
||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) {
|
||
throw new BadRequestException(
|
||
`No expert resend step is active. Current step: ${claimCase.workflow?.currentStep ?? "none"}`,
|
||
);
|
||
}
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for resend action. Current status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
const r = claimCase.evaluation?.damageExpertResend;
|
||
if (!r || r.fulfilledAt) {
|
||
throw new BadRequestException("There is no active expert resend request to acknowledge.");
|
||
}
|
||
if (
|
||
normalizeResendDocumentKeys(r.resendDocuments).length > 0 ||
|
||
normalizeResendPartKeys(r.resendCarParts).length > 0
|
||
) {
|
||
throw new BadRequestException(
|
||
"Upload all requested documents and part photos before completing the resend step.",
|
||
);
|
||
}
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
effectiveUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const updated = await this.claimCaseDbService.findById(claimRequestId);
|
||
return {
|
||
claimRequestId,
|
||
status: String(updated?.status ?? ""),
|
||
claimStatus: String(updated?.claimStatus ?? ""),
|
||
currentStep: String(updated?.workflow?.currentStep ?? ""),
|
||
message: "Claim returned to the damage expert queue.",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Upload required document
|
||
*/
|
||
async uploadRequiredDocumentV2(
|
||
claimRequestId: string,
|
||
body: UploadRequiredDocumentV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||
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 documents');
|
||
}
|
||
|
||
const step = claimCase.workflow?.currentStep;
|
||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||
if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (isResendUpload) {
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
if (!documentKeyAllowedForExpertResend(claimCase, body.documentKey)) {
|
||
throw new BadRequestException(
|
||
`Document "${body.documentKey}" was not requested by the damage expert for this resend.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// CAR_BODY claims only allow 7 document types (no guilty_*)
|
||
const blameForUpload = claimCase.blameRequestId
|
||
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
|
||
: null;
|
||
const isCarBodyUpload = blameForUpload?.type === BlameRequestType.CAR_BODY;
|
||
const guiltyKeys = ['guilty_driving_license_front', 'guilty_driving_license_back', 'guilty_car_card_front', 'guilty_car_card_back', 'guilty_metal_plate'];
|
||
if (isCarBodyUpload && guiltyKeys.includes(body.documentKey)) {
|
||
throw new BadRequestException(`Document type ${body.documentKey} is not required for CAR_BODY claims`);
|
||
}
|
||
|
||
// Initial flow: each document once. Expert resend: allow replacement.
|
||
if (!isResendUpload) {
|
||
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey)
|
||
?? (claimCase.requiredDocuments as any)?.[body.documentKey];
|
||
if (existingDoc?.uploaded) {
|
||
throw new ConflictException(`Document ${body.documentKey} has already been uploaded`);
|
||
}
|
||
}
|
||
|
||
const fileUrl = buildFileLink(file.path);
|
||
|
||
// Create document reference in claim-required-documents collection
|
||
const docRef = await this.claimRequiredDocumentDbService.create({
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
claimId: new Types.ObjectId(claimRequestId),
|
||
documentType: body.documentKey,
|
||
uploadedAt: new Date(),
|
||
});
|
||
|
||
// Update claim case
|
||
const updateData: any = {
|
||
[`requiredDocuments.${body.documentKey}`]: {
|
||
fileId: docRef._id,
|
||
filePath: file.path,
|
||
fileName: file.filename,
|
||
uploaded: true,
|
||
uploadedAt: new Date(),
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: 'DOCUMENT_UPLOADED',
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || 'User',
|
||
actorType: 'user',
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
documentKey: body.documentKey,
|
||
fileName: file.filename,
|
||
fileId: docRef._id.toString(),
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
let allDocumentsUploaded = false;
|
||
let remaining = 0;
|
||
|
||
if (!isResendUpload) {
|
||
// Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY)
|
||
const totalDocsRequired = isCarBodyUpload ? 8 : 13;
|
||
const currentDocs = claimCase.requiredDocuments || new Map();
|
||
const uploadedCount =
|
||
(currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
|
||
allDocumentsUploaded = uploadedCount >= totalDocsRequired;
|
||
remaining = totalDocsRequired - uploadedCount;
|
||
|
||
// If all documents uploaded, user flow complete (captures were done earlier in v2)
|
||
if (allDocumentsUploaded) {
|
||
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||
updateData["claimStatus"] = ClaimStatus.PENDING;
|
||
updateData["workflow.currentStep"] =
|
||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||
updateData["workflow.nextStep"] =
|
||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||
updateData.$push.history = [
|
||
updateData.$push.history,
|
||
{
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
description: "All required documents uploaded",
|
||
},
|
||
},
|
||
{
|
||
type: "STEP_COMPLETED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
description:
|
||
"User submission complete. Claim ready for damage expert review.",
|
||
},
|
||
},
|
||
];
|
||
updateData.$push["workflow.completedSteps"] =
|
||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||
}
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
|
||
|
||
if (isResendUpload) {
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
currentUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const refreshed = await this.claimCaseDbService.findById(claimRequestId);
|
||
const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt;
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
documentKey: body.documentKey,
|
||
fileUrl,
|
||
allDocumentsUploaded: false,
|
||
expertResendComplete,
|
||
currentStep: refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
message: expertResendComplete
|
||
? "Expert resend requirements are complete. Your claim is back in the damage expert queue."
|
||
: "Document uploaded for expert resend.",
|
||
};
|
||
}
|
||
|
||
const message = allDocumentsUploaded
|
||
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
documentKey: body.documentKey,
|
||
fileUrl,
|
||
allDocumentsUploaded,
|
||
currentStep: allDocumentsUploaded
|
||
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
message,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error('uploadRequiredDocumentV2 failed', error);
|
||
throw new InternalServerErrorException('Failed to upload document');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Capture car angle or damaged part
|
||
*/
|
||
async capturePartV2(
|
||
claimRequestId: string,
|
||
body: CapturePartV2Dto,
|
||
file: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<CapturePartV2ResponseDto> {
|
||
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 capture parts');
|
||
}
|
||
|
||
const capStep = claimCase.workflow?.currentStep;
|
||
const isResendCapture = capStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||
if (!isResendCapture && capStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
|
||
throw new BadRequestException(
|
||
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||
);
|
||
}
|
||
if (isResendCapture) {
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for expert resend uploads. Status: ${claimCase.status}`,
|
||
);
|
||
}
|
||
if (body.captureType !== "part") {
|
||
throw new BadRequestException(
|
||
"During expert resend only damaged-part photos can be uploaded (not car angles).",
|
||
);
|
||
}
|
||
if (!partKeyAllowedForExpertResend(claimCase, body.captureKey)) {
|
||
throw new BadRequestException(
|
||
`Part "${body.captureKey}" was not requested by the damage expert for this resend.`,
|
||
);
|
||
}
|
||
if (canonicalizeClaimCarAngleKey(body.captureKey)) {
|
||
throw new BadRequestException(
|
||
"During expert resend only damaged-part photos are allowed, not car angles (front/back/left/right).",
|
||
);
|
||
}
|
||
}
|
||
|
||
const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey);
|
||
if (body.captureType === "angle" && !angleCanon) {
|
||
throw new BadRequestException(
|
||
`Invalid angle captureKey "${body.captureKey}". Use one of: front, back, left, right.`,
|
||
);
|
||
}
|
||
const treatAsAngle =
|
||
!isResendCapture &&
|
||
(body.captureType === "angle" ||
|
||
(body.captureType === "part" && angleCanon !== null));
|
||
|
||
const fileUrl = buildFileLink(file.path);
|
||
const captureData = {
|
||
path: file.path,
|
||
fileName: file.filename,
|
||
url: fileUrl,
|
||
capturedAt: new Date(),
|
||
};
|
||
|
||
const updateData: any = {
|
||
$push: {
|
||
history: {
|
||
type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED",
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || 'User',
|
||
actorType: 'user',
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
...(treatAsAngle && angleCanon
|
||
? { resolvedCarAngleKey: angleCanon }
|
||
: {}),
|
||
fileName: file.filename,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
|
||
if (treatAsAngle && angleCanon) {
|
||
updateData[`media.carAngles.${angleCanon}`] = captureData;
|
||
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
|
||
angleCanon,
|
||
body.captureKey,
|
||
selectedBefore,
|
||
);
|
||
if (unsetFieldKeys.length) {
|
||
updateData.$unset = {};
|
||
for (const k of unsetFieldKeys) {
|
||
updateData.$unset[`media.damagedParts.${k}`] = "";
|
||
}
|
||
}
|
||
} else {
|
||
updateData[`media.damagedParts.${body.captureKey}`] = captureData;
|
||
}
|
||
|
||
if (
|
||
isResendCapture &&
|
||
body.captureType === "part" &&
|
||
!selectedBefore.includes(body.captureKey)
|
||
) {
|
||
updateData.$addToSet = { "damage.selectedParts": body.captureKey };
|
||
}
|
||
|
||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||
claimRequestId,
|
||
updateData,
|
||
);
|
||
|
||
if (isResendCapture) {
|
||
await this.tryFinalizeExpertResendAfterUserAction(
|
||
claimRequestId,
|
||
currentUserId,
|
||
claimCase.owner?.fullName || "User",
|
||
);
|
||
const refreshed = await this.claimCaseDbService.findById(claimRequestId);
|
||
const expertResendComplete = !!refreshed?.evaluation?.damageExpertResend?.fulfilledAt;
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
fileUrl,
|
||
allCapturesComplete: expertResendComplete,
|
||
expertResendComplete,
|
||
currentStep:
|
||
refreshed?.workflow?.currentStep || ClaimWorkflowStep.USER_EXPERT_RESEND,
|
||
message: expertResendComplete
|
||
? "Expert resend requirements are complete. Your claim is back in the damage expert queue."
|
||
: "Part photo uploaded for expert resend.",
|
||
};
|
||
}
|
||
|
||
const hasCapture = (data: any, key: string) =>
|
||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||
|
||
// Use same logic as getCaptureRequirementsV2 to determine completion
|
||
const anglesKeys = ['front', 'back', 'left', 'right'];
|
||
const carAnglesData = updatedClaim?.media?.carAngles as any;
|
||
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
|
||
const selectedParts = updatedClaim?.damage?.selectedParts || [];
|
||
|
||
const anglesCaptured = anglesKeys.filter((k) =>
|
||
hasClaimCarAngleCapture(
|
||
carAnglesData,
|
||
damagedPartsData,
|
||
k as ClaimCarAngleKey,
|
||
),
|
||
).length;
|
||
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
|
||
|
||
const allCapturesComplete =
|
||
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
|
||
|
||
if (allCapturesComplete) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||
'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||
'workflow.nextStep': ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||
$push: {
|
||
'workflow.completedSteps': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
history: {
|
||
type: 'STEP_COMPLETED',
|
||
actor: {
|
||
actorId: new Types.ObjectId(currentUserId),
|
||
actorName: claimCase.owner?.fullName || 'User',
|
||
actorType: 'user',
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
description:
|
||
'All car angles and damaged parts captured. Please upload required documents.',
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured);
|
||
const message = allCapturesComplete
|
||
? 'All captures complete. Please proceed to upload required documents.'
|
||
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
|
||
|
||
return {
|
||
claimRequestId: claimCase._id.toString(),
|
||
captureType: body.captureType,
|
||
captureKey: body.captureKey,
|
||
fileUrl,
|
||
allCapturesComplete,
|
||
currentStep: allCapturesComplete
|
||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||
message,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error('capturePartV2 failed', error);
|
||
throw new InternalServerErrorException('Failed to capture part');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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: Claim owner signs final priced expert reply (single-party), same intent as blame
|
||
* `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and
|
||
* workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION).
|
||
*/
|
||
async submitOwnerInsurerApprovalSignV2(
|
||
claimRequestId: string,
|
||
agree: boolean,
|
||
branchId: string,
|
||
signFile: Express.Multer.File,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string; fullName?: string },
|
||
): Promise<{
|
||
message: string;
|
||
claimRequestId: string;
|
||
status: ClaimCaseStatus;
|
||
claimStatus: ClaimStatus;
|
||
currentStep?: ClaimWorkflowStep;
|
||
accepted: boolean;
|
||
}> {
|
||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||
throw new BadRequestException("Invalid claim request id");
|
||
}
|
||
if (!signFile) {
|
||
throw new BadRequestException("A signature file is required");
|
||
}
|
||
|
||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claimCase) {
|
||
throw new NotFoundException("Claim request 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 (damaged party) can sign at this step.",
|
||
);
|
||
}
|
||
|
||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
||
throw new BadRequestException(
|
||
`Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`,
|
||
);
|
||
}
|
||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
|
||
throw new BadRequestException(
|
||
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
|
||
);
|
||
}
|
||
if (claimCase.claimStatus !== ClaimStatus.APPROVED) {
|
||
throw new BadRequestException(
|
||
`Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`,
|
||
);
|
||
}
|
||
|
||
if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
|
||
throw new ConflictException("You have already submitted a signature for this claim.");
|
||
}
|
||
|
||
const activeReply =
|
||
claimCase.evaluation?.damageExpertReplyFinal ||
|
||
claimCase.evaluation?.damageExpertReply;
|
||
if (!activeReply?.submittedAt) {
|
||
throw new BadRequestException("No expert reply is available to sign off on yet.");
|
||
}
|
||
|
||
const rawBranchId = (branchId ?? "").trim();
|
||
if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) {
|
||
throw new BadRequestException(
|
||
"branchId is required and must be a valid MongoDB ObjectId.",
|
||
);
|
||
}
|
||
const branchDoc = await this.branchDbService.findById(rawBranchId);
|
||
if (!branchDoc) {
|
||
throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`);
|
||
}
|
||
const ownerClientId = claimCase.owner?.clientId;
|
||
if (!ownerClientId || String(branchDoc.clientKey) !== String(ownerClientId)) {
|
||
throw new ForbiddenException(
|
||
"This branch does not belong to the insurer for this claim.",
|
||
);
|
||
}
|
||
const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply(
|
||
activeReply as { parts?: unknown[] },
|
||
);
|
||
if (
|
||
branchIdsOnPricing.size > 0 &&
|
||
!branchIdsOnPricing.has(String(rawBranchId))
|
||
) {
|
||
throw new BadRequestException(
|
||
"branchId must match a branch used in the expert pricing for this claim.",
|
||
);
|
||
}
|
||
|
||
const signDoc = await this.claimSignDbService.create({
|
||
fileName: signFile.filename,
|
||
userId: effectiveUserId,
|
||
path: signFile.path,
|
||
requestId: new Types.ObjectId(claimRequestId),
|
||
} as any);
|
||
const signId = new Types.ObjectId(String((signDoc as any)._id));
|
||
const signedAt = new Date();
|
||
const approvalPayload = {
|
||
agree,
|
||
branchId: new Types.ObjectId(rawBranchId),
|
||
signDetailId: signId,
|
||
signedAt,
|
||
};
|
||
|
||
const historyActor = {
|
||
actorId: new Types.ObjectId(effectiveUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
};
|
||
|
||
if (!agree) {
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
"evaluation.ownerInsurerApproval": approvalPayload,
|
||
"workflow.nextStep": null,
|
||
},
|
||
$push: {
|
||
history: {
|
||
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
return {
|
||
message:
|
||
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.REJECTED,
|
||
claimStatus: ClaimStatus.REJECTED,
|
||
currentStep: claimCase.workflow?.currentStep,
|
||
accepted: false,
|
||
};
|
||
}
|
||
|
||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||
$set: {
|
||
status: ClaimCaseStatus.COMPLETED,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
"evaluation.ownerInsurerApproval": approvalPayload,
|
||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
},
|
||
$push: {
|
||
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
|
||
history: {
|
||
type: "OWNER_SIGNED_INSURER_APPROVAL",
|
||
actor: historyActor,
|
||
timestamp: signedAt,
|
||
metadata: { branchId: rawBranchId },
|
||
},
|
||
},
|
||
});
|
||
|
||
return {
|
||
message: "Your signature has been recorded. The claim is completed.",
|
||
claimRequestId,
|
||
status: ClaimCaseStatus.COMPLETED,
|
||
claimStatus: ClaimStatus.APPROVED,
|
||
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||
accepted: true,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||
*/
|
||
async getMyClaimsV2(
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<GetMyClaimsV2ResponseDto> {
|
||
try {
|
||
let claims: any[];
|
||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||
const expertBlameIds = await this.blameRequestDbService
|
||
.find(
|
||
{
|
||
expertInitiated: true,
|
||
creationMethod: 'IN_PERSON',
|
||
initiatedByFieldExpertId: new Types.ObjectId(currentUserId),
|
||
},
|
||
{ select: '_id', lean: true },
|
||
)
|
||
.then((docs) => docs.map((d) => (d as any)._id));
|
||
claims = await this.claimCaseDbService.find(
|
||
{
|
||
$or: [
|
||
{ 'owner.userId': new Types.ObjectId(currentUserId) },
|
||
...(expertBlameIds.length
|
||
? [{ blameRequestId: { $in: expertBlameIds } }]
|
||
: []),
|
||
],
|
||
},
|
||
{ lean: true },
|
||
);
|
||
} else {
|
||
claims = await this.claimCaseDbService.find(
|
||
{ 'owner.userId': new Types.ObjectId(currentUserId) },
|
||
{ lean: true },
|
||
);
|
||
}
|
||
const list = (claims as any[]).map((c) => ({
|
||
claimRequestId: c._id.toString(),
|
||
publicId: c.publicId,
|
||
requestNo: c.requestNo,
|
||
status: c.status,
|
||
claimStatus: c.claimStatus || 'PENDING',
|
||
currentStep: c.workflow?.currentStep || '',
|
||
createdAt: c.createdAt,
|
||
blameRequestId: c.blameRequestId?.toString(),
|
||
})) as ClaimListItemV2Dto[];
|
||
return { list, total: list.length };
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error('getMyClaimsV2 failed', error);
|
||
throw new InternalServerErrorException('Failed to get claims list');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* V2 API: Get claim details by ID (owner only)
|
||
*/
|
||
async getClaimDetailsV2(
|
||
claimRequestId: string,
|
||
currentUserId: string,
|
||
actor?: { sub: string; role?: string },
|
||
): Promise<ClaimDetailsV2ResponseDto> {
|
||
try {
|
||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||
if (!claim) {
|
||
throw new NotFoundException('Claim request 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');
|
||
}
|
||
|
||
const hasCapture = (data: any, key: string) =>
|
||
data && (data instanceof Map ? data.get(key) : data[key]);
|
||
|
||
const requiredDocs = claim.requiredDocuments as any;
|
||
const requiredDocumentsStatus: Record<string, { uploaded: boolean; fileUrl?: string }> = {};
|
||
if (requiredDocs) {
|
||
const keys = requiredDocs instanceof Map ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs);
|
||
for (const k of keys) {
|
||
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||
requiredDocumentsStatus[k] = {
|
||
uploaded: !!doc?.uploaded,
|
||
fileUrl: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const carAnglesData = claim.media?.carAngles as any;
|
||
const damagedPartsForAngles = claim.media?.damagedParts as any;
|
||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||
for (const k of ['front', 'back', 'left', 'right']) {
|
||
const cap = getClaimCarAngleCaptureBlob(
|
||
carAnglesData,
|
||
damagedPartsForAngles,
|
||
k as ClaimCarAngleKey,
|
||
) as { url?: string; path?: string } | undefined;
|
||
carAngles[k] = {
|
||
captured: !!cap,
|
||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||
};
|
||
}
|
||
|
||
const getPartLabelFa = (key: string): string => {
|
||
const labels: Record<string, string> = {
|
||
hood: 'کاپوت',
|
||
trunk: 'صندوق عقب',
|
||
roof: 'سقف',
|
||
front_right_door: 'درب جلو راست',
|
||
front_left_door: 'درب جلو چپ',
|
||
rear_right_door: 'درب عقب راست',
|
||
rear_left_door: 'درب عقب چپ',
|
||
front_bumper: 'سپر جلو',
|
||
rear_bumper: 'سپر عقب',
|
||
front_right_fender: 'گلگیر جلو راست',
|
||
front_left_fender: 'گلگیر جلو چپ',
|
||
rear_right_fender: 'گلگیر عقب راست',
|
||
rear_left_fender: 'گلگیر عقب چپ',
|
||
};
|
||
return labels[key] || key;
|
||
};
|
||
|
||
const damagedPartsData = claim.media?.damagedParts as any;
|
||
const damagedParts: Record<
|
||
string,
|
||
{ label_fa: string; captured: boolean; url?: string }
|
||
> = {};
|
||
const resendPartKeys = normalizeResendPartKeys(
|
||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||
);
|
||
const partKeysForDetails = new Set<string>([
|
||
...((claim.damage?.selectedParts || []) as string[]),
|
||
...resendPartKeys,
|
||
]);
|
||
for (const p of partKeysForDetails) {
|
||
const cap = hasCapture(damagedPartsData, p);
|
||
damagedParts[p] = {
|
||
label_fa: getPartLabelFa(p),
|
||
captured: !!cap,
|
||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||
};
|
||
}
|
||
|
||
const er = claim.evaluation?.damageExpertResend;
|
||
const expertResend =
|
||
er && (er.resendDescription || (er.resendDocuments?.length ?? 0) > 0 || (er.resendCarParts?.length ?? 0) > 0)
|
||
? {
|
||
resendDescription: er.resendDescription,
|
||
resendDocuments: normalizeResendDocumentKeys(er.resendDocuments),
|
||
resendCarParts: Array.isArray(er.resendCarParts) ? er.resendCarParts : [],
|
||
fulfilledAt: er.fulfilledAt,
|
||
}
|
||
: undefined;
|
||
|
||
const maskSheba = (s?: string) =>
|
||
s ? s.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3') : undefined;
|
||
const maskNationalCode = (s?: string) =>
|
||
s ? s.replace(/^(.{2})(.*)(.{2})$/, '$1******$3') : undefined;
|
||
const isExpertViewer = actor?.role === RoleEnum.FIELD_EXPERT;
|
||
const ownerData = claim.owner
|
||
? {
|
||
userId: claim.owner.userId?.toString(),
|
||
clientId: claim.owner.clientId?.toString(),
|
||
...(claim.owner.userClientKey
|
||
? { userClientKey: claim.owner.userClientKey.toString() }
|
||
: {}),
|
||
fullName: claim.owner.fullName,
|
||
}
|
||
: undefined;
|
||
const moneyForUser = claim.money
|
||
? {
|
||
sheba: maskSheba(claim.money.sheba),
|
||
nationalCodeOfOwner: maskNationalCode(claim.money.nationalCodeOfInsurer),
|
||
}
|
||
: undefined;
|
||
const moneyForExpert = claim.money
|
||
? {
|
||
sheba: claim.money.sheba,
|
||
nationalCodeOfOwner: claim.money.nationalCodeOfInsurer,
|
||
}
|
||
: undefined;
|
||
|
||
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation);
|
||
|
||
return {
|
||
claimRequestId: claim._id.toString(),
|
||
publicId: claim.publicId,
|
||
requestNo: claim.requestNo,
|
||
status: claim.status,
|
||
claimStatus: claim.claimStatus || 'PENDING',
|
||
currentStep: claim.workflow?.currentStep || '',
|
||
nextStep: claim.workflow?.nextStep,
|
||
blameRequestId: claim.blameRequestId?.toString(),
|
||
blameRequestNo: claim.blameRequestNo,
|
||
...(ownerData ? { owner: ownerData } : {}),
|
||
vehicle: claim.vehicle,
|
||
selectedParts: claim.damage?.selectedParts,
|
||
otherParts: claim.damage?.otherParts,
|
||
money: isExpertViewer ? moneyForExpert : moneyForUser,
|
||
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
|
||
carAngles,
|
||
damagedParts,
|
||
expertResend,
|
||
evaluation: mappedEvaluation
|
||
? {
|
||
damageExpertReply: mappedEvaluation.damageExpertReply,
|
||
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
||
}
|
||
: undefined,
|
||
...(isExpertViewer
|
||
? {
|
||
userRating: claim.userRating
|
||
? {
|
||
progressSpeed: claim.userRating.progressSpeed,
|
||
registrationEase: claim.userRating.registrationEase,
|
||
overallEvaluation: claim.userRating.overallEvaluation,
|
||
comment: claim.userRating.comment,
|
||
createdAt: claim.userRating.createdAt,
|
||
}
|
||
: undefined,
|
||
}
|
||
: {}),
|
||
createdAt: (claim as any).createdAt,
|
||
updatedAt: (claim as any).updatedAt,
|
||
};
|
||
} catch (error) {
|
||
if (error instanceof HttpException) throw error;
|
||
this.logger.error('getClaimDetailsV2 failed', error);
|
||
throw new InternalServerErrorException('Failed to get claim details');
|
||
}
|
||
}
|
||
|
||
private async generateUniqueClaimNumber(): Promise<string> {
|
||
const prefix = "CL";
|
||
const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits
|
||
return `${prefix}${randomPart}`;
|
||
}
|
||
}
|