forked from Yara724/api
6797 lines
227 KiB
TypeScript
6797 lines
227 KiB
TypeScript
import {
|
||
BadGatewayException,
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
HttpException,
|
||
HttpStatus,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
ConflictException,
|
||
InternalServerErrorException,
|
||
} from "@nestjs/common";
|
||
import { Types } from "mongoose";
|
||
import { isAxiosError } 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 { 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 { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||
import { ClaimRequiredDocumentType } 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,
|
||
mapExpertResendCarPartsForClient,
|
||
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,
|
||
parseCatalogPartIdInput,
|
||
partLookupKey,
|
||
resolvePartCaptureIndex,
|
||
} from "src/helpers/outer-damage-parts";
|
||
import { normalizeMoneyAmountString } from "src/utils/unicode-digits";
|
||
|
||
import {
|
||
CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS,
|
||
capturePhaseSequenceMessage,
|
||
getClaimCaptureProgress,
|
||
isCapturePhaseDamagedPartyDocKey,
|
||
isClaimCaptureStepComplete,
|
||
} from "src/helpers/claim-capture-phase";
|
||
import { HttpService } from "@nestjs/axios";
|
||
import { firstValueFrom } from "rxjs";
|
||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||
|
||
@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 readonly httpService: HttpService,
|
||
) {}
|
||
|
||
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 firstValueFrom(
|
||
this.httpService.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 firstValueFrom(
|
||
this.httpService.post(this.GET_APP_TOKEN_URL, "", {
|
||
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 (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 firstValueFrom(
|
||
this.httpService.post(this.LOGIN_URL, "", {
|
||
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 (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 firstValueFrom(
|
||
this.httpService.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 (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 firstValueFrom(
|
||
this.httpService.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 (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,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
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(
|
||
String(blameRequest?.expert?.decision?.guiltyPartyId) ===
|
||
String(blameRequest?.parties[0]?.person?.userId)
|
||
? blameRequest?.parties[0]?.person?.clientId
|
||
: blameRequest?.parties[1]?.person?.clientId,
|
||
),
|
||
}
|
||
: {}),
|
||
},
|
||
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,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
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,
|
||
inquiries: (blameRequest as any).inquiries ?? {},
|
||
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 &&
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||
) {
|
||
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 &&
|
||
claimCase.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND
|
||
) {
|
||
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.",
|
||
);
|
||
}
|
||
|
||
let step = claimCase.workflow?.currentStep;
|
||
|
||
// 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: 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(currentUserId),
|
||
actorName: claimCase.owner?.fullName || "User",
|
||
actorType: "user",
|
||
},
|
||
timestamp: new Date(),
|
||
metadata: {
|
||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||
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,
|
||
claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
||
claimCase.damage?.selectedParts,
|
||
).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 pricedCatalogIds = activeExpert?.parts?.length
|
||
? new Set(
|
||
activeExpert.parts
|
||
.filter((p) => p.factorNeeded !== true)
|
||
.map((p) => parseCatalogPartIdInput(p.partId))
|
||
.filter((id): id is number => id != null),
|
||
)
|
||
: new Set<number>();
|
||
|
||
if (partsIn.length && pricingObjectionEligible) {
|
||
for (const op of partsIn) {
|
||
const catalogId = parseCatalogPartIdInput(op.partId);
|
||
if (catalogId == null || !pricedCatalogIds.has(catalogId)) {
|
||
throw new BadRequestException(
|
||
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const objectionParts = partsIn.map((p) => ({
|
||
partId: parseCatalogPartIdInput(p.partId)!,
|
||
reason: p.reason?.trim() || undefined,
|
||
partPrice:
|
||
p.partPrice != null && String(p.partPrice).trim() !== ""
|
||
? normalizeMoneyAmountString(String(p.partPrice))
|
||
: undefined,
|
||
partSalary:
|
||
p.partSalary != null && String(p.partSalary).trim() !== ""
|
||
? normalizeMoneyAmountString(String(p.partSalary))
|
||
: undefined,
|
||
typeOfDamage:
|
||
p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
|
||
carPartDamage: p.carPartDamage?.trim() || undefined,
|
||
side: p.side?.trim() || undefined,
|
||
}));
|
||
|
||
const newParts = newPartsIn.map((p) => ({
|
||
partId:
|
||
p.partId != null && parseCatalogPartIdInput(p.partId) != null
|
||
? parseCatalogPartIdInput(p.partId)!
|
||
: new Types.ObjectId().toString(),
|
||
partName: p.partName.trim(),
|
||
side: p.side?.trim() || undefined,
|
||
}));
|
||
|
||
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) => partLookupKey(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 = partLookupKey(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,
|
||
...(pendingResendGate && {
|
||
"evaluation.damageExpertResend.fulfilledAt": new Date(),
|
||
}),
|
||
},
|
||
$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 },
|
||
query: ListQueryV2Dto = {},
|
||
): 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[];
|
||
|
||
const paged = applyListQueryV2(
|
||
list,
|
||
{
|
||
publicId: (r) => r.publicId,
|
||
createdAt: (r) => r.createdAt,
|
||
requestNo: (r) => r.requestNo,
|
||
status: (r) => r.status,
|
||
searchExtras: (r) =>
|
||
[
|
||
r.claimRequestId,
|
||
r.claimStatus,
|
||
r.currentStep,
|
||
r.blameRequestId,
|
||
].filter(Boolean) as string[],
|
||
},
|
||
query,
|
||
);
|
||
|
||
return {
|
||
list: paged.list,
|
||
total: paged.total,
|
||
page: paged.page,
|
||
limit: paged.limit,
|
||
totalPages: paged.totalPages,
|
||
};
|
||
} 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,
|
||
carTypeDetails,
|
||
claim.damage?.selectedParts,
|
||
);
|
||
const displayParts = [...selectedNormDetails];
|
||
const seenDetailKeys = new Set(
|
||
selectedNormDetails.map((p) => partLookupKey(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 = partLookupKey(extra);
|
||
if (!seenDetailKeys.has(k)) {
|
||
displayParts.push(extra);
|
||
seenDetailKeys.add(k);
|
||
}
|
||
}
|
||
|
||
const damagedParts = buildEnrichedDamagedParts({
|
||
selectedParts: displayParts,
|
||
damagedPartsData: damagedPartsData,
|
||
evaluationBlock: (claim as any).evaluation,
|
||
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
||
getDamagedPartCaptureBlob,
|
||
hasDamagedPartCapture,
|
||
catalogLikeKeyFromPart,
|
||
buildFileLink,
|
||
});
|
||
|
||
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: mapExpertResendCarPartsForClient(
|
||
er.resendCarParts,
|
||
{
|
||
carType: carTypeDetails,
|
||
selectedParts: claim.damage?.selectedParts,
|
||
damagedPartsData: claim.media?.damagedParts,
|
||
buildUrl: (path) => buildFileLink(path),
|
||
},
|
||
),
|
||
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}`;
|
||
}
|
||
}
|