Files
yara724api/src/claim-request-management/claim-request-management.service.ts
2026-05-17 15:39:23 +03:30

6260 lines
220 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { SandHubService } from "src/sand-hub/sand-hub.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 { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { UserRatingDto } from "./dto/user-rating.dto";
import {
canFinalizeExpertResend,
documentKeyAllowedForExpertResend,
normalizeResendDocumentKeys,
normalizeResendPartKeys,
partKeyAllowedForExpertResend,
} from "src/helpers/claim-expert-resend";
import {
claimCaseStatusAllowsOwnerFactorUploadDuringRevision,
claimCaseStatusAllowsOwnerInsurerSignStep,
classifyV2ExpertPricingParts,
getActiveV2ExpertReply,
objectionDisallowedDueToOutstandingFactorWorkflow,
} from "src/helpers/claim-v2-expert-reply-workflow";
import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance";
import {
canonicalizeClaimCarAngleKey,
getClaimCarAngleCaptureBlob,
getDamagedPartCaptureBlob,
hasClaimCarAngleCapture,
hasDamagedPartCapture,
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";
import {
carPartDamageLabelForFactorRecord,
catalogItemToSelectedPart,
catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray,
type DamageSelectedPartV2,
migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts,
partIdentityKey,
resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts";
import {
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
capturePhaseSequenceMessage,
getClaimCaptureProgress,
isCapturePhaseDamagedPartyDocKey,
isClaimCaptureStepComplete,
} from "src/helpers/claim-capture-phase";
@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 readonly sandHubService: SandHubService,
) {}
private requiredDocumentKeysV2(isCarBody: boolean): string[] {
const damagedPartyKeys = [
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
];
if (isCarBody) return damagedPartyKeys;
return [
...damagedPartyKeys,
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
];
}
private isRequiredDocumentUploadedOnClaim(claimCase: any, key: string): boolean {
const doc =
(claimCase.requiredDocuments as any)?.get?.(key) ??
(claimCase.requiredDocuments as any)?.[key];
return !!doc?.uploaded;
}
private allV2OwnerDocumentsComplete(
claimCase: any,
isCarBody: boolean,
assumeUploadedKey?: string,
): boolean {
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
const ok =
k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k);
if (!ok) return false;
}
if (isCarBody) {
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
const okGreen =
assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g);
if (!okGreen) return false;
}
return true;
}
private countRemainingV2OwnerDocuments(
claimCase: any,
isCarBody: boolean,
assumeUploadedKey?: string,
): number {
let n = 0;
for (const k of this.requiredDocumentKeysV2(isCarBody)) {
const ok =
k === assumeUploadedKey ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, k);
if (!ok) n++;
}
if (isCarBody) {
const g = ClaimRequiredDocumentType.CAR_GREEN_CARD;
const okGreen =
assumeUploadedKey === g ? true : this.isRequiredDocumentUploadedOnClaim(claimCase, g);
if (!okGreen) n++;
}
return n;
}
private parsePlateFromCompactString(
plateId: string | undefined,
): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null {
if (!plateId) return null;
const parts = String(plateId).split("-");
if (parts.length !== 4) return null;
const [irRaw, leftRaw, alphaRaw, centerRaw] = parts;
const ir = Number(irRaw);
const leftDigits = Number(leftRaw);
const centerDigits = Number(centerRaw);
const centerAlphabet = String(alphaRaw || "").trim();
if (
!Number.isFinite(ir) ||
!Number.isFinite(leftDigits) ||
!Number.isFinite(centerDigits) ||
!centerAlphabet
) {
return null;
}
return { leftDigits, centerAlphabet, centerDigits, ir };
}
private resolveOwnershipPlateForClaim(
claimCase: any,
blameRequest?: any,
): { leftDigits: number; centerAlphabet: string; centerDigits: number; ir: number } | null {
const p = claimCase?.vehicle?.plate;
if (
p &&
Number.isFinite(Number(p.leftDigits)) &&
Number.isFinite(Number(p.centerDigits)) &&
Number.isFinite(Number(p.ir)) &&
String(p.centerAlphabet || "").trim()
) {
return {
leftDigits: Number(p.leftDigits),
centerAlphabet: String(p.centerAlphabet).trim(),
centerDigits: Number(p.centerDigits),
ir: Number(p.ir),
};
}
if (!blameRequest || !Array.isArray(blameRequest.parties)) return null;
const ownerUserId = claimCase?.owner?.userId?.toString?.();
const matchedParty =
blameRequest.parties.find(
(party: any) => String(party?.person?.userId || "") === String(ownerUserId || ""),
) || blameRequest.parties.find((party: any) => party?.role === PartyRole.FIRST);
const compactPlateId = matchedParty?.vehicle?.plateId;
return this.parsePlateFromCompactString(compactPlateId);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Outer-parts catalog returned to clients (user app + expert panel).
*
* The shape intentionally mirrors how items are persisted under
* `damage.selectedParts` so the front-end can match catalog rows to the
* stored selection by `id` / `catalogKey` without renaming fields:
*
* { id, name, side, label_fa, catalogKey, carType }
*
* - `name` is side-agnostic (`backWheel`, not `left_backWheel`).
* - `label_fa` is disambiguated with the side in parentheses when multiple
* catalog rows share the same Farsi title (e.g. `چرخ عقب (چپ)`).
* - `catalogKey` keeps the original full key (`left_backWheel`) for clients
* that already address parts by it.
*/
getOuterPartsCatalogV2(
carType?: ClaimVehicleTypeV2,
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] {
const buildForType = (
type: ClaimVehicleTypeV2,
items: OuterPartCatalogItem[],
): (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] =>
items.map((p) => ({
...catalogItemToSelectedPart(p, items),
carType: type,
}));
if (carType) {
const items = OUTER_PARTS_BY_CAR_TYPE[carType] || [];
return buildForType(carType, items);
}
const out: (DamageSelectedPartV2 & { carType: ClaimVehicleTypeV2 })[] = [];
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
out.push(...buildForType(type as ClaimVehicleTypeV2, items));
}
return out.sort((a, b) => {
if (a.carType === b.carType) return (a.id ?? 0) - (b.id ?? 0);
return String(a.carType).localeCompare(String(b.carType));
});
}
/**
* Internal raw catalog (kept in the original `OuterPartCatalogItem` shape so
* existing internal code that indexes by `key`/`titleFa` keeps working).
* Public API consumers should use `getOuterPartsCatalogV2` instead.
*/
private getOuterPartsRawCatalog(
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;
}
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 selectedSlugStrings = this.carDamagePartDtoToOuterPartSlugs(
dto.carPartDamage,
);
if (!selectedSlugStrings.length) {
throw new BadRequestException(
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
);
}
const selectedPartObjs = normalizeDamageSelectedParts(
selectedSlugStrings,
working.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
undefined,
);
const damagedPartsStubs = selectedPartObjs.map((p) => ({
id: p.id ?? undefined,
name: p.name,
side: p.side,
label_fa: p.label_fa,
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
}));
const updated = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
"damage.selectedParts": selectedPartObjs,
"media.damagedParts": damagedPartsStubs,
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: selectedPartObjs,
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,
carType?: ClaimVehicleTypeV2,
): 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 } : {}),
};
}
if (nextPart.carPartDamage != null) {
nextPart.carPartDamage = migrateExpertReplyCarPartDamageToUnified(
nextPart.carPartDamage,
carType,
);
}
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) =>
carPartDamageLabelForFactorRecord(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: carPartDamageLabelForFactorRecord(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 (!claimCaseStatusAllowsOwnerFactorUploadDuringRevision(claim.status)) {
throw new BadRequestException(
"Factors can only be uploaded while the claim is in a repair-factor upload phase (see ClaimCaseStatus).",
);
}
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
throw new BadRequestException(
"Factors can only be uploaded while the claim needs owner action (claimStatus NEEDS_REVISION).",
);
}
if (claim.workflow?.currentStep !== ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS) {
throw new BadRequestException(
`Repair factors are uploaded at workflow step ${ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS} (after accepting priced lines when applicable). Current: ${String(claim.workflow?.currentStep ?? "")}.`,
);
}
const flowParts = finalReply.parts ?? [];
const pricingShape = classifyV2ExpertPricingParts(flowParts);
if (
pricingShape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
) {
throw new BadRequestException(
"Sign acceptance of the priced repair lines first (`PUT .../owner-insurer-approval/sign` while claimStatus is NEEDS_REVISION at INSURER_REVIEW), then upload factors for factor-needed parts.",
);
}
const partNameForRecord =
carPartDamageLabelForFactorRecord(part.carPartDamage) ||
String(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: {
status: ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
"workflow.nextStep": ClaimWorkflowStep.INSURER_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",
);
}
// DISABLED: 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(", ")}`,
// );
// }
const selectedPartDocs = selectedItems.map((p) =>
catalogItemToSelectedPart(p, catalog),
);
const damagedPartsInitial = selectedPartDocs.map((p) => ({
id: p.id ?? undefined,
name: p.name,
side: p.side,
label_fa: p.label_fa,
...(p.catalogKey ? { catalogKey: p.catalogKey } : {}),
}));
const selectedPartIds = selectedItems.map((p) => p.id);
// 6. Update claim case with selected parts and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
"damage.selectedParts": selectedPartDocs,
"media.damagedParts": damagedPartsInitial,
"vehicle.carType": selectedCarType,
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
$unset: { "damage.selectedOuterParts": "" },
$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: selectedPartDocs,
selectedPartIds,
carType: selectedCarType,
partsCount: selectedPartDocs?.length,
description: `User selected ${selectedPartDocs?.length} damaged outer parts`,
},
},
},
},
);
if (!updatedClaim) {
throw new InternalServerErrorException("Failed to update claim case");
}
this.logger.log(
`Outer parts selected for claim ${claimRequestId}: ${selectedPartDocs?.length} parts`,
);
// 7. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
selectedParts: selectedPartDocs,
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 blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
: null;
const ownershipPlate = this.resolveOwnershipPlateForClaim(claimCase, blameRequest);
if (!ownershipPlate) {
throw new BadRequestException(
"Could not resolve vehicle plate for ownership validation.",
);
}
// External inquiry checks before moving workflow:
// 1) ownership check for nationalCode + plate
// await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode);
// 2) sheba check for nationalCode + sheba
await this.sandHubService.getShebaValidation(nationalCode, shebaNumber);
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).
// Uses the raw catalog shape (`{id, key, titleFa, side, ...}`) because the
// map below is keyed on the full `key` (e.g. `left_backfender`).
const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsRawCatalog();
const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
}
// 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,
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
};
});
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 of the car' },
{ key: 'back', label_fa: 'عقب ماشین', label_en: 'Back of the car' },
{ key: 'left', label_fa: 'چپ ماشین', label_en: 'Left of the car' },
{ key: 'right', label_fa: 'راست ماشین', label_en: 'Right of the car' },
].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,
),
}));
const carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
carType,
(claimCase.damage as any)?.selectedOuterParts,
);
const damagedParts = selectedNorm.map((sp) => {
const catalogMeta = sp.catalogKey
? catalogByKey.get(sp.catalogKey)
: sp.name
? catalogByKey.get(catalogLikeKeyFromPart(sp))
: undefined;
const labelEnName = sp.name
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
return {
id: sp.id ?? catalogMeta?.id,
key: sp.name,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
label_en: labelEnName,
captured: hasDamagedPartCapture(
claimCase.media?.damagedParts as any,
ck,
selectedNorm,
),
};
});
// 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 captureProgress = getClaimCaptureProgress(claimCase);
const capturePhaseDocsRemaining = captureProgress.capturePhaseDocsRemaining;
const partsRemaining = Math.max(
0,
captureProgress.partsTotal - captureProgress.partsCaptured,
);
const anglesRemaining = Math.max(
0,
captureProgress.anglesTotal - captureProgress.anglesCaptured,
);
const postCaptureDocsRemaining = requiredDocuments.filter(
(d) => !d.preferUploadDuringCapture && !d.uploaded,
).length;
const totalRemaining =
partsRemaining + anglesRemaining + capturePhaseDocsRemaining;
return {
claimRequestId: claimCase._id.toString(),
publicId: claimCase.publicId,
currentStep: claimCase.workflow?.currentStep || '',
captureSequencePhase: captureProgress.sequencePhase,
captureSequenceHint: capturePhaseSequenceMessage(
captureProgress.sequencePhase,
),
requiredDocuments,
carAngles,
damagedParts,
totalRemaining,
progress: {
documentsUploaded,
documentsTotal: requiredDocuments.length,
anglesCaptured,
anglesTotal: carAngles.length,
partsCaptured,
partsTotal: damagedParts.length,
capturePhaseDocsRemaining,
postCaptureDocumentsRemaining: postCaptureDocsRemaining,
},
};
} 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;
const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
isCapturePhaseDamagedPartyDocKey(body.documentKey);
if (!isResendUpload) {
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) {
throw new BadRequestException(
`During ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} only chassis, engine, and damaged metal plate photos may be uploaded here (${CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.join(", ")}), and only after all damaged parts and car angles are captured.`,
);
}
if (isCapturePhaseDocUpload) {
const captureProgress = getClaimCaptureProgress(claimCase);
if (!captureProgress.partsComplete) {
throw new BadRequestException(
"Upload photos for all selected damaged parts before uploading chassis, engine, or metal plate photos.",
);
}
if (!captureProgress.anglesComplete) {
throw new BadRequestException(
"Capture all four car angles (front, back, left, right) before uploading chassis, engine, or metal plate photos.",
);
}
}
const allowedInitialUploadStep =
step === ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS || isCapturePhaseDocUpload;
if (!allowedInitialUploadStep) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES} (capture-phase documents only), 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;
// Will be true when this upload also completes capture-phase docs AND
// every angle + every selected damaged part has already been captured.
// In that case we advance the workflow exactly like `capturePartV2` does
// when the user finishes the last capture — otherwise the user would be
// stuck in CAPTURE_PART_DAMAGES with all captures done but no more
// captures to upload to trigger the transition.
let captureStepCompletedOnThisUpload = false;
if (!isResendUpload) {
if (isCapturePhaseDocUpload) {
const afterThis = (k: string) =>
k === body.documentKey || this.isRequiredDocumentUploadedOnClaim(claimCase, k);
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter((k) => !afterThis(k)).length;
allDocumentsUploaded = false;
if (remaining === 0) {
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
assumeCapturePhaseDocKey: body.documentKey,
});
if (
progressAfterDoc.partsComplete &&
progressAfterDoc.anglesComplete &&
progressAfterDoc.capturePhaseDocsComplete
) {
captureStepCompletedOnThisUpload = true;
updateData["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
updateData["workflow.currentStep"] =
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
updateData["workflow.nextStep"] =
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
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.CAPTURE_PART_DAMAGES,
description:
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
},
},
];
updateData.$push["workflow.completedSteps"] =
ClaimWorkflowStep.CAPTURE_PART_DAMAGES;
}
}
} else {
allDocumentsUploaded = this.allV2OwnerDocumentsComplete(
claimCase,
isCarBodyUpload,
body.documentKey,
);
remaining = this.countRemainingV2OwnerDocuments(
claimCase,
isCarBodyUpload,
body.documentKey,
);
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 = isCapturePhaseDocUpload
? captureStepCompletedOnThisUpload
? "Capture-phase documents and all damage captures are complete. Please proceed to upload the remaining required documents."
: remaining > 0
? `Document saved. ${remaining} capture-phase document(s) still required (chassis, engine, metal plate).`
: capturePhaseSequenceMessage(
getClaimCaptureProgress(claimCase, {
assumeCapturePhaseDocKey: body.documentKey,
}).sequencePhase,
)
: 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: isCapturePhaseDocUpload
? captureStepCompletedOnThisUpload
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
: 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).",
);
}
}
if (!isResendCapture) {
const captureProgressBefore = getClaimCaptureProgress(claimCase);
const willBeAngle =
body.captureType === "angle" ||
(body.captureType === "part" &&
canonicalizeClaimCarAngleKey(body.captureKey) !== null);
if (willBeAngle && !captureProgressBefore.partsComplete) {
throw new BadRequestException(
"Upload photos for all selected damaged parts before capturing car angles.",
);
}
}
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 carType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedOuterLegacy = (claimCase.damage as any)?.selectedOuterParts;
const selectedBeforeNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
carType,
selectedOuterLegacy,
);
let nextSelected = [...selectedBeforeNorm];
let nextMedia = coerceDamagedPartsMediaToArray(
claimCase.media?.damagedParts,
nextSelected,
);
if (treatAsAngle && angleCanon) {
updateData[`media.carAngles.${angleCanon}`] = captureData;
if (!Array.isArray(claimCase.media?.damagedParts)) {
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
angleCanon,
body.captureKey,
claimCase.damage?.selectedParts,
carType,
selectedOuterLegacy,
);
if (unsetFieldKeys.length) {
updateData.$unset = {};
for (const k of unsetFieldKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
}
}
}
} else {
let idx = resolvePartCaptureIndex(
body.captureKey,
nextSelected,
nextMedia,
);
if (isResendCapture && body.captureType === "part" && idx < 0) {
const added = normalizeDamageSelectedParts(
[body.captureKey],
carType,
undefined,
);
const row =
added[0] || {
id: null,
name: body.captureKey,
side: "",
label_fa: body.captureKey,
catalogKey: body.captureKey,
};
nextSelected.push(row);
idx = nextSelected.length - 1;
nextMedia.push({});
}
if (idx < 0) {
throw new BadRequestException(
`Unknown damaged-part captureKey "${body.captureKey}". Use catalog id, 0-based index, or catalog key.`,
);
}
while (nextMedia.length <= idx) {
nextMedia.push({});
}
const base = nextSelected[idx] || {
id: null,
name: String(body.captureKey),
side: "",
label_fa: String(body.captureKey),
};
nextMedia[idx] = {
...(base.id != null ? { id: base.id } : {}),
name: base.name,
side: base.side,
label_fa: base.label_fa,
...(base.catalogKey ? { catalogKey: base.catalogKey } : {}),
...nextMedia[idx],
...captureData,
};
updateData["media.damagedParts"] = nextMedia;
if (
isResendCapture &&
nextSelected.length !== selectedBeforeNorm.length
) {
updateData["damage.selectedParts"] = nextSelected;
}
}
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 captureProgress = getClaimCaptureProgress(updatedClaim);
const allCapturesComplete = isClaimCaptureStepComplete(updatedClaim);
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:
'Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.',
},
},
},
});
}
const partsRemaining = Math.max(
0,
captureProgress.partsTotal - captureProgress.partsCaptured,
);
const anglesRemaining = Math.max(
0,
captureProgress.anglesTotal - captureProgress.anglesCaptured,
);
const remaining =
partsRemaining +
anglesRemaining +
captureProgress.capturePhaseDocsRemaining;
const message = allCapturesComplete
? "All captures complete. Please proceed to upload the remaining required documents."
: `${body.captureType === "angle" ? "Angle" : "Part"} captured successfully. ${capturePhaseSequenceMessage(captureProgress.sequencePhase)} (${remaining} item(s) left in this step).`;
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.UPLOAD_REQUIRED_DOCUMENTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`,
);
}
const captureProgress = getClaimCaptureProgress(claimCase);
if (!captureProgress.partsComplete) {
throw new BadRequestException(
"Upload photos for all selected damaged parts before uploading the walk-around video.",
);
}
if (!captureProgress.anglesComplete) {
throw new BadRequestException(
"Capture all four car angles (front, back, left, right) before uploading the walk-around video.",
);
}
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).
*
* Priced-flow: insurer-review post-expert statuses (`INSURER_REVIEW_AWAITING_OWNER_SIGN`, `INSURER_REVIEW_MIXED_FACTORS_PENDING`, or legacy `WAITING_FOR_INSURER_APPROVAL`) @ `INSURER_REVIEW` — either NEEDS_REVISION (mixed-priced gate before factor upload)
* or APPROVED (final totals after pricing / factor validation); never while uploading factors or while expert validates.
* Legacy: objection during unpaid expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).
* Post: resets owner signatures when applicable and returns claim to WAITING_FOR_DAMAGE_EXPERT.
*/
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');
}
if (claimCase.evaluation?.objection?.submittedAt) {
throw new ConflictException('An objection has already been submitted for this claim.');
}
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
const pendingResendGate =
hasExpertResend &&
claimCase.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
claimCase.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
if (claimCase.evaluation?.damageExpertReplyFinal) {
throw new ConflictException(
'The expert already submitted the final reply after objection; submit accept/reject via the owner signer.',
);
}
const activeExpert = getActiveV2ExpertReply(
claimCase as unknown as { evaluation?: Record<string, unknown> },
);
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.',
);
}
let pricingObjectionEligible = false;
if (
claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status) &&
claimCase.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
!claimCase.evaluation?.ownerInsurerApproval?.signedAt
) {
if (objectionDisallowedDueToOutstandingFactorWorkflow(claimCase)) {
throw new BadRequestException(
'Objections are paused while factor files are uploading or the expert is validating repair factors.',
);
}
const shape = classifyV2ExpertPricingParts(activeExpert?.parts ?? []);
const atMixedPartialGate =
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
shape.mixedFactorAndPrice &&
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
const atFinalPricingGate =
claimCase.claimStatus === ClaimStatus.APPROVED;
pricingObjectionEligible = atMixedPartialGate || atFinalPricingGate;
}
if (!(pendingResendGate || pricingObjectionEligible)) {
throw new BadRequestException(
'No open objection window. Use this after a priced insurer-review step without a recorded final approval, ' +
'or while completing an unpaid expert resend.',
);
}
if (!pendingResendGate && pricingObjectionEligible && !activeExpert?.parts?.length) {
throw new BadRequestException(
'A priced assessment is required before you can object.',
);
}
const pricedPartIdSet =
activeExpert?.parts?.length ?
new Set(
activeExpert.parts
.filter((p) => p.factorNeeded !== true)
.map((p) => String((p as { partId?: string }).partId ?? ""))
.filter(Boolean),
)
: new Set<string>();
if (partsIn.length && pricingObjectionEligible) {
for (const op of partsIn) {
if (!pricedPartIdSet.has(String(op.partId))) {
throw new BadRequestException(
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
);
}
}
}
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 objectionCarType = claimCase.vehicle?.carType as
| ClaimVehicleTypeV2
| undefined;
const mergedNorm = normalizeDamageSelectedParts(
claimCase.damage?.selectedParts,
objectionCarType,
(claimCase.damage as any)?.selectedOuterParts,
);
const mergedKeys = new Set(mergedNorm.map((p) => partIdentityKey(p)));
for (const np of newParts) {
const name = np.partName?.trim();
if (!name) continue;
const side = String(np.side ?? "").trim();
const row = {
id: null as number | null,
name,
side,
label_fa: name,
};
const k = partIdentityKey(row);
if (!mergedKeys.has(k)) {
mergedNorm.push(row);
mergedKeys.add(k);
}
}
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': mergedNorm,
},
$unset: {
'evaluation.ownerPricedPartsApproval': "",
'evaluation.ownerInsurerApproval': "",
},
$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 signature on expert pricing.
*
* Phase 1 (mixed priced + factor-needed lines): `INSURER_REVIEW_MIXED_FACTORS_PENDING` (or legacy `WAITING_FOR_INSURER_APPROVAL`), NEEDS_REVISION, INSURER_REVIEW,
* owner has not yet accepted priced lines — agreeing records `evaluation.ownerPricedPartsApproval` and moves the
* workflow to OWNER_UPLOAD_FACTOR_DOCUMENTS. Rejecting rejects the whole case.
*
* Phase 2 (final): INSURER_REVIEW_AWAITING_OWNER_SIGN (or legacy WAITING_FOR_INSURER_APPROVAL), APPROVED, INSURER_REVIEW — full accept/reject completes the case.
*/
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;
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
}> {
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 (!claimCaseStatusAllowsOwnerInsurerSignStep(claimCase.status)) {
throw new BadRequestException(
`Claim is not waiting for your insurer-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 ?? "")}`,
);
}
const active =
claimCase.evaluation?.damageExpertReplyFinal ||
claimCase.evaluation?.damageExpertReply;
if (!active?.submittedAt) {
throw new BadRequestException("No expert reply is available to sign off on yet.");
}
const shape = classifyV2ExpertPricingParts(active.parts ?? []);
const pricedBranchReply = {
parts: (active.parts ?? []).filter((p: { factorNeeded?: boolean }) => !p.factorNeeded),
};
const isMixedPartialGate =
shape.mixedFactorAndPrice &&
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
const isFinalApprovalGate =
claimCase.claimStatus === ClaimStatus.APPROVED &&
!claimCase.evaluation?.ownerInsurerApproval?.signedAt;
if (!isMixedPartialGate && !isFinalApprovalGate) {
throw new BadRequestException(
"No owner signature action is available at this state. " +
"If repair factors are pending upload, complete the priced-line signature first (when claimStatus is NEEDS_REVISION). " +
"If you already completed the final approval signature, refresh the claim.",
);
}
if (isMixedPartialGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("Claim already has a final approval record; refresh the claim state.");
}
if (isFinalApprovalGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("You have already submitted a signature for this claim.");
}
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 =
isMixedPartialGate
? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply)
: this.collectBranchIdsFromClaimExpertReply(active 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 scopedApprovalPayload = {
agree,
branchId: new Types.ObjectId(rawBranchId),
signDetailId: signId,
signedAt,
};
const historyActor = {
actorId: new Types.ObjectId(effectiveUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
};
/** Mixed reply: priced lines acceptance before factor uploads */
if (isMixedPartialGate) {
if (!agree) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
"workflow.nextStep": null,
},
$push: {
history: {
type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS",
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,
phase: "PRICED_PARTS_FOR_FACTORS",
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
claimStatus: ClaimStatus.NEEDS_REVISION,
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
"workflow.currentStep": ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
$push: {
history: {
type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD",
actor: historyActor,
timestamp: signedAt,
metadata: { branchId: rawBranchId },
},
},
});
return {
message:
"Priced repair lines accepted. Upload repair factor files for each factor-needed part; the claim will return to the damage expert for cost validation.",
claimRequestId,
status: claimCase.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
currentStep: ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
accepted: true,
phase: "PRICED_PARTS_FOR_FACTORS",
};
}
/** Final accept / reject (no outstanding factor gate at this step) */
if (!agree) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
"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,
phase: "FINAL_APPROVAL",
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
"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,
phase: "FINAL_APPROVAL",
};
}
/**
* 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 — or FIELD_EXPERT with enriched money/rating fields).
* Owners receive `ownerGuidance` computed from workflow state.
*/
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 carTypeDetails = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNormDetails = normalizeDamageSelectedParts(
claim.damage?.selectedParts,
carTypeDetails,
(claim.damage as any)?.selectedOuterParts,
);
const damagedPartsData = claim.media?.damagedParts as any;
const resendPartKeys = normalizeResendPartKeys(
claim.evaluation?.damageExpertResend?.resendCarParts,
);
const displayParts = [...selectedNormDetails];
const seenDetailKeys = new Set(
selectedNormDetails.map((p) => partIdentityKey(p)),
);
for (const rk of resendPartKeys) {
const extra =
normalizeDamageSelectedParts([rk], carTypeDetails, undefined)[0] ||
{
id: null,
name: rk,
side: "",
label_fa: rk,
catalogKey: rk,
};
const k = partIdentityKey(extra);
if (!seenDetailKeys.has(k)) {
displayParts.push(extra);
seenDetailKeys.add(k);
}
}
const damagedParts = displayParts.map((sp, index) => {
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
const cap = getDamagedPartCaptureBlob(
damagedPartsData,
ck,
selectedNormDetails,
) as { url?: string; path?: string; fileName?: string } | undefined;
return {
index,
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
...(cap?.path ? { path: cap.path } : {}),
...(cap?.fileName ? { fileName: cap.fileName } : {}),
};
});
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,
claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
);
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: selectedNormDetails,
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
? {
ownerGuidance: buildClaimDetailsV2OwnerGuidance(
claim,
claim._id.toString(),
),
}
: {}),
...(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}`;
}
}