forked from Yara724/api
Compare commits
22 Commits
f8193b6622
...
07c4e5126a
| Author | SHA1 | Date | |
|---|---|---|---|
| 07c4e5126a | |||
|
|
010846acd9 | ||
|
|
cc926d4668 | ||
| fdb75d52f7 | |||
|
|
186f6c5837 | ||
|
|
3fb90cf1c9 | ||
| 5e5ad0e1b3 | |||
|
|
82bb232d28 | ||
| 45392ad268 | |||
|
|
9c62dc4d3a | ||
| d1bc64bb6d | |||
|
|
be58b7e47e | ||
| 78e86e5745 | |||
|
|
fe163419c0 | ||
|
|
eb648d8a87 | ||
| b856cb59f9 | |||
|
|
a52b7a0a72 | ||
|
|
7998649a89 | ||
|
|
74c91c73b6 | ||
|
|
9e2cec5bc3 | ||
| e95cb4c255 | |||
|
|
c1a54baaf0 |
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
ApiBody,
|
||||
ApiAcceptedResponse,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
@@ -39,13 +40,33 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
export class ActorAuthController {
|
||||
constructor(private readonly actorAuthService: ActorAuthService) {}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/genuine")
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Genuine actor registration",
|
||||
description:
|
||||
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||
})
|
||||
@ApiBody({ type: GenuineRegisterDto })
|
||||
async registerGenuine(@Body() body: GenuineRegisterDto) {
|
||||
return await this.actorAuthService.genuineRegister(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/legal")
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Legal actor registration",
|
||||
description:
|
||||
"Deprecated — kept only for legacy clients. Use the unified actor onboarding flow instead. Will be removed.",
|
||||
})
|
||||
@ApiBody({ type: LegalRegisterDto })
|
||||
async registerLegal(@Body() body: LegalRegisterDto) {
|
||||
return await this.actorAuthService.legalRegister(body);
|
||||
@@ -76,9 +97,44 @@ export class ActorAuthController {
|
||||
@UseGuards(LocalActorAuthGuard)
|
||||
@Post("login")
|
||||
@Roles()
|
||||
@ApiOperation({
|
||||
summary: "Actor login (returns access + refresh tokens)",
|
||||
description:
|
||||
"Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `[\"damage_expert\"]` — together with the actor's email/`username` and password. On success the response contains the JWT pair and the resolved profile.",
|
||||
})
|
||||
@ApiBody({
|
||||
type: LoginActorDto,
|
||||
description: "user verify otp -- call this api and get a tokens",
|
||||
description:
|
||||
"Login payload. Pick one of the swagger examples below to see the exact body shape per role.",
|
||||
examples: {
|
||||
company: {
|
||||
summary: "Insurer / company portal",
|
||||
description: "Sample tenant credentials for the insurer (company) panel.",
|
||||
value: {
|
||||
role: "company",
|
||||
username: "saman_insurer@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
expert: {
|
||||
summary: "Blame expert panel",
|
||||
description: "Sample credentials for a blame (`expert`) account.",
|
||||
value: {
|
||||
role: "expert",
|
||||
username: "blame@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
damage_expert: {
|
||||
summary: "Damage expert (claim) panel",
|
||||
description: "Sample credentials for a damage-expert account.",
|
||||
value: {
|
||||
role: "damage_expert",
|
||||
username: "claim@gmail.com",
|
||||
password: "123321",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async login(@Body() body, @Req() req, @ClientKey() client) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { FieldExpertDbService } from "src/users/entities/db-service/field-expert
|
||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
// import { MailService } from "src/utils/mail/mail.service";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||
|
||||
function pick(obj: Record<string, any>, keys: string[]) {
|
||||
const out: Record<string, any> = {};
|
||||
@@ -51,7 +51,7 @@ export class ActorAuthService {
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
) {}
|
||||
|
||||
// TODO convrt to class for dynamic controller
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Types } from "mongoose";
|
||||
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
|
||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||
@Injectable()
|
||||
@@ -24,7 +24,7 @@ export class UserAuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userDbService: UserDbService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly otpCreator: OtpService,
|
||||
private readonly otpCreator: OtpGeneratorService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
// import { MailModule } from "src/utils/mail/mail.module";
|
||||
import { OtpModule } from "src/utils/otp/otp.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
@@ -20,7 +19,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
HashModule,
|
||||
OtpModule,
|
||||
PassportModule,
|
||||
SmsOrchestrationModule,
|
||||
JwtModule.register({
|
||||
|
||||
@@ -98,6 +98,7 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
DamageImageDbService,
|
||||
VideoCaptureDbService,
|
||||
ClaimRequiredDocumentDbService,
|
||||
ClaimSignDbService,
|
||||
],
|
||||
})
|
||||
export class ClaimRequestManagementModule {}
|
||||
|
||||
@@ -68,6 +68,7 @@ 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,
|
||||
@@ -76,6 +77,7 @@ import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-st
|
||||
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,
|
||||
@@ -111,12 +113,24 @@ import {
|
||||
catalogItemToSelectedPart,
|
||||
catalogLikeKeyFromPart,
|
||||
coerceDamagedPartsMediaToArray,
|
||||
type DamageSelectedPartV2,
|
||||
migrateExpertReplyCarPartDamageToUnified,
|
||||
normalizeDamageSelectedParts,
|
||||
partIdentityKey,
|
||||
resolvePartCaptureIndex,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
|
||||
/** Same `requiredDocuments` keys as in getCaptureRequirementsV2; upload allowed during CAPTURE_PART_DAMAGES. */
|
||||
const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_metal_plate",
|
||||
] as const;
|
||||
|
||||
function isCapturePhaseDamagedPartyDocKey(key: string): boolean {
|
||||
return (CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClaimRequestManagementService {
|
||||
private readonly logger = new Logger(ClaimRequestManagementService.name);
|
||||
@@ -158,13 +172,188 @@ export class ClaimRequestManagementService {
|
||||
private readonly branchDbService: BranchDbService,
|
||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||
private readonly publicIdService: PublicIdService,
|
||||
private readonly sandHubService: SandHubService,
|
||||
) {}
|
||||
|
||||
private requiredDocumentKeysV2(isCarBody: boolean): string[] {
|
||||
const damagedPartyKeys = [
|
||||
"damaged_driving_license_front",
|
||||
"damaged_driving_license_back",
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_car_card_front",
|
||||
"damaged_car_card_back",
|
||||
"damaged_metal_plate",
|
||||
];
|
||||
if (isCarBody) return damagedPartyKeys;
|
||||
return [
|
||||
...damagedPartyKeys,
|
||||
"guilty_driving_license_front",
|
||||
"guilty_driving_license_back",
|
||||
"guilty_car_card_front",
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
];
|
||||
}
|
||||
|
||||
private isRequiredDocumentUploadedOnClaim(claimCase: any, key: string): boolean {
|
||||
const doc =
|
||||
(claimCase.requiredDocuments as any)?.get?.(key) ??
|
||||
(claimCase.requiredDocuments as any)?.[key];
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
private capturePhaseDamagedPartyDocsComplete(claimCase: any): boolean {
|
||||
return CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.every((k) =>
|
||||
this.isRequiredDocumentUploadedOnClaim(claimCase, k),
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] {
|
||||
/**
|
||||
* 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,
|
||||
@@ -178,10 +367,7 @@ export class ClaimRequestManagementService {
|
||||
out.push({ ...p, carType: t });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
if (a.carType === b.carType) return a.id - b.id;
|
||||
return String(a.carType).localeCompare(String(b.carType));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
private userDamageDetail(blRequest: RequestManagementModel) {
|
||||
@@ -4008,17 +4194,17 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// 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(", ")}`,
|
||||
);
|
||||
}
|
||||
// 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),
|
||||
@@ -4196,6 +4382,22 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
const blameRequest = claimCase.blameRequestId
|
||||
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
|
||||
: null;
|
||||
const ownershipPlate = this.resolveOwnershipPlateForClaim(claimCase, blameRequest);
|
||||
if (!ownershipPlate) {
|
||||
throw new BadRequestException(
|
||||
"Could not resolve vehicle plate for ownership validation.",
|
||||
);
|
||||
}
|
||||
|
||||
// External inquiry checks before moving workflow:
|
||||
// 1) ownership check for nationalCode + plate
|
||||
// await this.sandHubService.getCarOwnershipInfo(ownershipPlate, nationalCode);
|
||||
// 2) sheba check for nationalCode + sheba
|
||||
await this.sandHubService.getShebaValidation(nationalCode, shebaNumber);
|
||||
|
||||
const updatePayload: any = {
|
||||
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
|
||||
'money.sheba': shebaNumber,
|
||||
@@ -4325,12 +4527,14 @@ export class ClaimRequestManagementService {
|
||||
throw new ForbiddenException('Only the claim owner can view capture requirements');
|
||||
}
|
||||
|
||||
// Build car-type aware outer-parts lookup (complete source: static catalog)
|
||||
// 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.getOuterPartsCatalogV2();
|
||||
: this.getOuterPartsRawCatalog();
|
||||
const catalogByKey = new Map<string, OuterPartCatalogItem>();
|
||||
for (const item of catalogForType) {
|
||||
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
||||
@@ -4366,6 +4570,7 @@ export class ClaimRequestManagementService {
|
||||
label_en: doc.label_en,
|
||||
category: doc.category,
|
||||
uploaded: docData?.uploaded || false,
|
||||
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4584,10 +4789,23 @@ export class ClaimRequestManagementService {
|
||||
|
||||
const step = claimCase.workflow?.currentStep;
|
||||
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
|
||||
if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
|
||||
throw new BadRequestException(
|
||||
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`,
|
||||
);
|
||||
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(", ")}).`,
|
||||
);
|
||||
}
|
||||
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) {
|
||||
@@ -4662,55 +4880,127 @@ export class ClaimRequestManagementService {
|
||||
let allDocumentsUploaded = false;
|
||||
let remaining = 0;
|
||||
|
||||
if (!isResendUpload) {
|
||||
// Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY)
|
||||
const totalDocsRequired = isCarBodyUpload ? 8 : 13;
|
||||
const currentDocs = claimCase.requiredDocuments || new Map();
|
||||
const uploadedCount =
|
||||
(currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
|
||||
allDocumentsUploaded = uploadedCount >= totalDocsRequired;
|
||||
remaining = totalDocsRequired - uploadedCount;
|
||||
// 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 all documents uploaded, user flow complete (captures were done earlier in v2)
|
||||
if (allDocumentsUploaded) {
|
||||
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||
updateData["claimStatus"] = ClaimStatus.PENDING;
|
||||
updateData["workflow.currentStep"] =
|
||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||
updateData["workflow.nextStep"] =
|
||||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||||
updateData.$push.history = [
|
||||
updateData.$push.history,
|
||||
{
|
||||
type: "STEP_COMPLETED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: "user",
|
||||
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) {
|
||||
// After this upload all 3 capture-phase docs will be on the claim.
|
||||
// Check angles + parts captures using the in-memory claim media.
|
||||
const carAnglesData = (claimCase.media as any)?.carAngles;
|
||||
const damagedPartsData = (claimCase.media as any)?.damagedParts;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
claimCase.damage?.selectedParts,
|
||||
claimCase.vehicle?.carType as ClaimVehicleTypeV2,
|
||||
(claimCase.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const anglesKeys = ["front", "back", "left", "right"];
|
||||
const anglesCaptured = anglesKeys.filter((k) =>
|
||||
hasClaimCarAngleCapture(
|
||||
carAnglesData,
|
||||
damagedPartsData,
|
||||
k as ClaimCarAngleKey,
|
||||
),
|
||||
).length;
|
||||
const partsCaptured = selectedNorm.filter((sp) => {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
return hasDamagedPartCapture(damagedPartsData, ck, selectedNorm);
|
||||
}).length;
|
||||
|
||||
if (
|
||||
anglesCaptured >= 4 &&
|
||||
partsCaptured >= selectedNorm.length
|
||||
) {
|
||||
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",
|
||||
},
|
||||
},
|
||||
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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
];
|
||||
updateData.$push["workflow.completedSteps"] =
|
||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4737,18 +5027,28 @@ export class ClaimRequestManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
const message = allDocumentsUploaded
|
||||
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||
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) before you can finish damage capture.`
|
||||
: "Document saved. All capture-phase documents are uploaded; finish car angles and part photos to proceed."
|
||||
: allDocumentsUploaded
|
||||
? "All documents uploaded successfully. Your claim is now ready for damage expert review."
|
||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||
|
||||
return {
|
||||
claimRequestId: claimCase._id.toString(),
|
||||
documentKey: body.documentKey,
|
||||
fileUrl,
|
||||
allDocumentsUploaded,
|
||||
currentStep: allDocumentsUploaded
|
||||
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
currentStep: isCapturePhaseDocUpload
|
||||
? captureStepCompletedOnThisUpload
|
||||
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
|
||||
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
|
||||
: allDocumentsUploaded
|
||||
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
|
||||
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||
message,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -4998,8 +5298,11 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}).length;
|
||||
|
||||
const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim);
|
||||
const allCapturesComplete =
|
||||
anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length;
|
||||
anglesCaptured >= 4 &&
|
||||
partsCaptured >= selectedNormAfter.length &&
|
||||
capturePhaseDocsDone;
|
||||
|
||||
if (allCapturesComplete) {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
@@ -5019,18 +5322,23 @@ export class ClaimRequestManagementService {
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||
description:
|
||||
'All car angles and damaged parts captured. Please upload required documents.',
|
||||
'Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const captureDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||
(k) => !this.isRequiredDocumentUploadedOnClaim(updatedClaim, k),
|
||||
).length;
|
||||
const remaining =
|
||||
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured);
|
||||
(4 - anglesCaptured) +
|
||||
(selectedNormAfter.length - partsCaptured) +
|
||||
captureDocsRemaining;
|
||||
const message = allCapturesComplete
|
||||
? 'All captures complete. Please proceed to upload required documents.'
|
||||
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
|
||||
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} items remaining (angles, parts, and capture-phase documents).`;
|
||||
|
||||
return {
|
||||
claimRequestId: claimCase._id.toString(),
|
||||
|
||||
@@ -638,13 +638,13 @@ Optional: upload car green card file in the same step.
|
||||
summary: "Get Capture Requirements (V2)",
|
||||
description: `
|
||||
**Get list of what needs to be captured:**
|
||||
- Required documents (13 items)
|
||||
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
|
||||
- Car angles (4 items: front, back, left, right)
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
|
||||
**V2 order:** Complete angles and part photos first, then required documents.
|
||||
**V2 order:** During \`CAPTURE_PART_DAMAGES\`, complete angles, part photos, walk-around video, and the three \`preferUploadDuringCapture\` documents (same upload-document API). Then complete the remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
|
||||
@@ -35,6 +35,13 @@ export class RequiredDocumentItem {
|
||||
enum: ['general', 'damaged_party', 'guilty_party'],
|
||||
})
|
||||
category: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'When true, the client should upload this file during CAPTURE_PART_DAMAGES (same POST upload-document endpoint and `requiredDocuments` keys). Capture cannot finish until these are uploaded.',
|
||||
example: true,
|
||||
})
|
||||
preferUploadDuringCapture?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,19 +140,54 @@ export class SetClaimVehicleTypeV2Dto {
|
||||
carType: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape returned by `GET .../outer-parts-catalog` (both user and expert
|
||||
* controllers). It mirrors how items are persisted under
|
||||
* `damage.selectedParts` (see `DamageSelectedPartV2BodyDto`) so the front-end
|
||||
* can match catalog rows to stored selections without any field renaming:
|
||||
* `name` is side-agnostic, `label_fa` is disambiguated with the side in
|
||||
* parentheses, and the original full catalog key (`left_backfender`) is
|
||||
* exposed as `catalogKey`.
|
||||
*/
|
||||
export class OuterPartCatalogItemDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({
|
||||
description: "Static catalog id (unique across all car types)",
|
||||
example: 102,
|
||||
})
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
@ApiProperty({
|
||||
description: "Side-agnostic part name (matches stored part `name`)",
|
||||
example: "backWheel",
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
titleFa: string;
|
||||
@ApiProperty({
|
||||
description: "Vehicle side / region",
|
||||
enum: OuterPartSideV2,
|
||||
example: "left",
|
||||
})
|
||||
side: string;
|
||||
|
||||
@ApiProperty({ enum: OuterPartSideV2 })
|
||||
side: OuterPartSideV2;
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Display label in Farsi, with side disambiguator in parentheses when needed",
|
||||
example: "چرخ عقب (چپ)",
|
||||
})
|
||||
label_fa: string;
|
||||
|
||||
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false })
|
||||
@ApiProperty({
|
||||
description: "Original full catalog key (matches stored `catalogKey`)",
|
||||
example: "left_backWheel",
|
||||
required: false,
|
||||
})
|
||||
catalogKey?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Vehicle type this catalog row belongs to",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: false,
|
||||
example: "suv",
|
||||
})
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, IsNotEmpty, IsOptional } from "class-validator";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateBranchDto {
|
||||
@ApiProperty({ example: "شهرک غرب" })
|
||||
@@ -31,4 +37,18 @@ export class CreateBranchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiProperty({ required: false, example: true, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
example: "2025-01-01T00:00:00.000Z",
|
||||
description: "Branch activity start datetime (ISO string)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
activityStartDate?: string;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,71 @@ export class BranchDbService {
|
||||
async findAll(insuranceId: string): Promise<BranchModel[]> {
|
||||
return await this.branchModel.find({
|
||||
clientKey: new Types.ObjectId(insuranceId),
|
||||
}, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
async findAllWithFilters(
|
||||
insuranceId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
isActive?: boolean;
|
||||
},
|
||||
): Promise<BranchModel[]> {
|
||||
const filter: any = { clientKey: new Types.ObjectId(insuranceId) };
|
||||
|
||||
if (typeof opts?.isActive === "boolean") {
|
||||
filter.isActive = opts.isActive;
|
||||
}
|
||||
|
||||
if (opts?.from || opts?.to) {
|
||||
filter.$or = [
|
||||
{
|
||||
createdAt: {
|
||||
...(opts.from ? { $gte: opts.from } : {}),
|
||||
...(opts.to ? { $lte: opts.to } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
activityStartDate: {
|
||||
...(opts.from ? { $gte: opts.from } : {}),
|
||||
...(opts.to ? { $lte: opts.to } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (opts?.search?.trim()) {
|
||||
const q = opts.search.trim();
|
||||
const rx = new RegExp(q, "i");
|
||||
filter.$and = [
|
||||
...(filter.$and || []),
|
||||
{
|
||||
$or: [
|
||||
{ name: rx },
|
||||
{ code: rx },
|
||||
{ city: rx },
|
||||
{ state: rx },
|
||||
{ address: rx },
|
||||
{ phoneNumber: rx },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return this.branchModel.find(filter).lean();
|
||||
}
|
||||
|
||||
async findByIds(ids: Types.ObjectId[]) {
|
||||
return this.branchModel
|
||||
.find({ _id: { $in: ids } })
|
||||
.select({ _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, isActive: 1, activityStartDate: 1, createdAt: 1, updatedAt: 1 })
|
||||
.lean();
|
||||
}
|
||||
|
||||
async findByIdAndUpdate(id: string, update: any): Promise<BranchModel | null> {
|
||||
return this.branchModel.findByIdAndUpdate(id, update, { new: true }).lean();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BranchModel | null> {
|
||||
|
||||
@@ -25,6 +25,12 @@ export class BranchModel {
|
||||
|
||||
@Prop()
|
||||
phoneNumber?: string;
|
||||
|
||||
@Prop({ type: Boolean, default: true })
|
||||
isActive?: boolean;
|
||||
|
||||
@Prop({ type: Date })
|
||||
activityStartDate?: Date;
|
||||
}
|
||||
|
||||
export const BranchSchema = SchemaFactory.createForClass(BranchModel);
|
||||
|
||||
@@ -120,11 +120,32 @@ export class ClaimDetailV2ResponseDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Expert reply payloads (first and/or final) when awaiting factor validation.',
|
||||
'Slice of `claim.evaluation` exposed to the damage expert. ' +
|
||||
'`damageExpertReply` / `damageExpertReplyFinal` are returned only while ' +
|
||||
'awaiting factor validation. `ownerInsurerApproval` and ' +
|
||||
'`ownerPricedPartsApproval` are returned whenever they exist on the ' +
|
||||
'claim — each carries `signLink` (resolved from `signDetailId`) so the ' +
|
||||
'front-end can render the user signature directly. Likewise, ' +
|
||||
'`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` ' +
|
||||
'expose `signLink` when a user comment signature is present.',
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
ownerInsurerApproval?: {
|
||||
agree: boolean;
|
||||
branchId?: string;
|
||||
signDetailId?: string;
|
||||
signLink?: string;
|
||||
signedAt?: Date | string;
|
||||
};
|
||||
ownerPricedPartsApproval?: {
|
||||
agree: boolean;
|
||||
branchId?: string;
|
||||
signDetailId?: string;
|
||||
signLink?: string;
|
||||
signedAt?: Date | string;
|
||||
};
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { distance as stringDistance } from "fastest-levenshtein"; // حتما ن
|
||||
import { Types } from "mongoose";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
||||
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
|
||||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||
@@ -216,8 +217,75 @@ export class ExpertClaimService {
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
private readonly userDbService: UserDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly claimSignDbService: ClaimSignDbService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Resolve a `claim-sign` document id to a downloadable file URL.
|
||||
* Mirrors the helper used in `ExpertInsurerService` so signature links are
|
||||
* exposed identically across the two expert-facing detail endpoints.
|
||||
*/
|
||||
private async claimSignLinkFromId(
|
||||
signDetailId: unknown,
|
||||
): Promise<string | undefined> {
|
||||
if (signDetailId == null || signDetailId === "") return undefined;
|
||||
const id = String(signDetailId);
|
||||
if (!Types.ObjectId.isValid(id)) return undefined;
|
||||
const doc = await this.claimSignDbService.findOne({
|
||||
_id: new Types.ObjectId(id),
|
||||
});
|
||||
const path = (doc as any)?.path;
|
||||
return path ? buildFileLink(String(path)) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deep-cloned `evaluation` payload with resolved file URLs the
|
||||
* front-end can render directly:
|
||||
*
|
||||
* - `damageExpertReply.userComment.signLink`
|
||||
* - `damageExpertReplyFinal.userComment.signLink`
|
||||
* - `ownerInsurerApproval.signLink`
|
||||
* - `ownerPricedPartsApproval.signLink`
|
||||
* - `damageExpertReply.parts[i].factorLink` (ObjectId → URL)
|
||||
* - `damageExpertReplyFinal.parts[i].factorLink` (ObjectId → URL)
|
||||
*
|
||||
* Returns `undefined` when there's no evaluation to enrich.
|
||||
*/
|
||||
private async enrichClaimEvaluationForExpert(
|
||||
evaluation: Record<string, unknown> | undefined | null,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
if (!evaluation || typeof evaluation !== "object") return undefined;
|
||||
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<string, unknown>;
|
||||
|
||||
const enrichReply = async (key: string) => {
|
||||
const reply = ev[key] as Record<string, unknown> | undefined;
|
||||
if (!reply) return;
|
||||
const uc = reply.userComment as Record<string, unknown> | undefined;
|
||||
if (uc?.signDetailId != null) {
|
||||
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
|
||||
reply.userComment = { ...uc, signLink };
|
||||
}
|
||||
// Resolve each `parts[].factorLink` ObjectId to a downloadable URL.
|
||||
// `populateFactorLinks` mutates in place — safe here because `ev` was
|
||||
// deep-cloned via JSON above, so we never touch the live mongoose doc.
|
||||
await this.populateFactorLinks(reply);
|
||||
};
|
||||
await enrichReply("damageExpertReply");
|
||||
await enrichReply("damageExpertReplyFinal");
|
||||
|
||||
const enrichApproval = async (key: string) => {
|
||||
const o = ev[key] as Record<string, unknown> | undefined;
|
||||
if (o?.signDetailId != null) {
|
||||
const signLink = await this.claimSignLinkFromId(o.signDetailId);
|
||||
ev[key] = { ...o, signLink };
|
||||
}
|
||||
};
|
||||
await enrichApproval("ownerInsurerApproval");
|
||||
await enrichApproval("ownerPricedPartsApproval");
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
/** Load immutable profile fields from `damage-expert` for the acting expert. */
|
||||
private async snapshotDamageExpert(actorId: string) {
|
||||
const doc = await this.damageExpertDbService.findById(actorId);
|
||||
@@ -2210,14 +2278,22 @@ export class ExpertClaimService {
|
||||
*
|
||||
* Validations:
|
||||
* - Claim must exist
|
||||
* - Status must be WAITING_FOR_DAMAGE_EXPERT
|
||||
* - Must not already be locked by another expert
|
||||
* - Status must be either:
|
||||
* • `WAITING_FOR_DAMAGE_EXPERT` (initial damage review queue), OR
|
||||
* • the factor-validation queue (`EXPERT_VALIDATING_REPAIR_FACTORS`,
|
||||
* or legacy `WAITING_FOR_INSURER_APPROVAL`, with
|
||||
* `claimStatus=UNDER_REVIEW` + `workflow.currentStep=EXPERT_COST_EVALUATION`).
|
||||
* - Must not already be locked by another expert.
|
||||
*
|
||||
* On success:
|
||||
* - Sets workflow.locked = true, workflow.lockedBy = actor
|
||||
* - Sets status = EXPERT_REVIEWING
|
||||
* - Sets claimStatus = UNDER_REVIEW
|
||||
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
|
||||
* - Always sets `workflow.locked=true`, `workflow.lockedBy=actor`,
|
||||
* `workflow.lockedAt/expiredAt`, and snapshots pre-lock queue state.
|
||||
* - For the **damage review** path: status → `EXPERT_REVIEWING`,
|
||||
* `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.
|
||||
* - For the **factor-validation** path: leaves `status`, `claimStatus`, and
|
||||
* `workflow.currentStep` untouched so the claim stays in the factor-validation
|
||||
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
|
||||
* treating it correctly when the lock expires).
|
||||
*/
|
||||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||||
requireActorClientKey(actor);
|
||||
@@ -2230,7 +2306,11 @@ export class ExpertClaimService {
|
||||
|
||||
assertClaimCaseForTenant(claim, actor);
|
||||
|
||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
|
||||
const isDamageReviewLock =
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||
|
||||
if (!isDamageReviewLock && !isFactorValidationLock) {
|
||||
throw new BadRequestException(
|
||||
`Claim is not available for locking. Current status: ${claim.status}`,
|
||||
);
|
||||
@@ -2257,9 +2337,8 @@ export class ExpertClaimService {
|
||||
const lockAt = new Date();
|
||||
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||
// Common lock fields written in both branches.
|
||||
const baseLockUpdate: Record<string, unknown> = {
|
||||
'workflow.locked': true,
|
||||
'workflow.lockedAt': lockAt,
|
||||
'workflow.expiredAt': expiredAt,
|
||||
@@ -2284,7 +2363,6 @@ export class ExpertClaimService {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
$push: {
|
||||
history: {
|
||||
type: "CLAIM_LOCKED",
|
||||
@@ -2294,10 +2372,28 @@ export class ExpertClaimService {
|
||||
actorType: "damage_expert",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: { note: `Claim locked by damage expert ${actor.fullName}` },
|
||||
metadata: {
|
||||
note: isFactorValidationLock
|
||||
? `Claim locked for factor validation by damage expert ${actor.fullName}`
|
||||
: `Claim locked by damage expert ${actor.fullName}`,
|
||||
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Damage-review lock advances status/step; factor-validation lock keeps them
|
||||
// intact so the claim remains in the factor-validation queue when the lock expires.
|
||||
const update: Record<string, unknown> = isFactorValidationLock
|
||||
? baseLockUpdate
|
||||
: {
|
||||
...baseLockUpdate,
|
||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
};
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, update);
|
||||
|
||||
await this.recordClaimExpertActivity({
|
||||
expertId: String(actor.sub),
|
||||
@@ -2523,7 +2619,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
// Price cap validation
|
||||
const PRICE_CAP = 30_000_000;
|
||||
const PRICE_CAP = 53_000_000;
|
||||
let totalPrice = 0;
|
||||
for (const part of reply.parts || []) {
|
||||
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
|
||||
@@ -3473,6 +3569,46 @@ export class ExpertClaimService {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Enrich the full evaluation block with `signLink`s for every place a user
|
||||
// signature is referenced, then assemble the response slice.
|
||||
//
|
||||
// We always expose owner approval blocks (when present in the DB) because
|
||||
// the signature URL is needed across all phases of the claim. The expert
|
||||
// reply payloads are still gated on `isFactorValidationPending` to match
|
||||
// the historical contract of this endpoint.
|
||||
const claimEvaluationRaw = (claim as any).evaluation as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const enrichedEvaluation = await this.enrichClaimEvaluationForExpert(
|
||||
claimEvaluationRaw,
|
||||
);
|
||||
|
||||
let evaluationForApi: Record<string, unknown> | undefined;
|
||||
if (enrichedEvaluation) {
|
||||
evaluationForApi = {};
|
||||
if (isFactorValidationPending) {
|
||||
if (enrichedEvaluation.damageExpertReply !== undefined) {
|
||||
evaluationForApi.damageExpertReply =
|
||||
enrichedEvaluation.damageExpertReply;
|
||||
}
|
||||
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
|
||||
evaluationForApi.damageExpertReplyFinal =
|
||||
enrichedEvaluation.damageExpertReplyFinal;
|
||||
}
|
||||
}
|
||||
if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
|
||||
evaluationForApi.ownerInsurerApproval =
|
||||
enrichedEvaluation.ownerInsurerApproval;
|
||||
}
|
||||
if (enrichedEvaluation.ownerPricedPartsApproval !== undefined) {
|
||||
evaluationForApi.ownerPricedPartsApproval =
|
||||
enrichedEvaluation.ownerPricedPartsApproval;
|
||||
}
|
||||
if (Object.keys(evaluationForApi).length === 0) {
|
||||
evaluationForApi = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
claimRequestId: claim._id.toString(),
|
||||
publicId: claim.publicId,
|
||||
@@ -3511,12 +3647,9 @@ export class ExpertClaimService {
|
||||
carAngles,
|
||||
damagedParts,
|
||||
awaitingFactorValidation: isFactorValidationPending,
|
||||
evaluation: isFactorValidationPending
|
||||
? {
|
||||
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
|
||||
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
|
||||
}
|
||||
: undefined,
|
||||
evaluation: evaluationForApi as
|
||||
| ClaimDetailV2ResponseDto["evaluation"]
|
||||
| undefined,
|
||||
objection,
|
||||
videoCapture,
|
||||
blameCase: blameCaseForApi,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiParam,
|
||||
ApiPropertyOptional,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
@@ -18,6 +19,9 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import { OuterPartCatalogItemDto } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
class InPersonVisitV2Dto {
|
||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||
@@ -32,7 +36,10 @@ class InPersonVisitV2Dto {
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||
export class ExpertClaimV2Controller {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) { }
|
||||
constructor(
|
||||
private readonly expertClaimService: ExpertClaimService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
@@ -44,6 +51,26 @@ export class ExpertClaimV2Controller {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same catalog as `GET v2/claim-request-management/outer-parts-catalog` so factor/resend payloads use identical part ids.
|
||||
*/
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(
|
||||
@Query("carType") carType?: ClaimVehicleTypeV2,
|
||||
): Promise<OuterPartCatalogItemDto[]> {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({
|
||||
summary: "List available claim requests for damage expert",
|
||||
@@ -72,7 +99,10 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Lock a claim request for review",
|
||||
description:
|
||||
"Claim must have status WAITING_FOR_DAMAGE_EXPERT. Locking sets status to EXPERT_REVIEWING and claimStatus to UNDER_REVIEW. Only one expert can lock a claim at a time.",
|
||||
"Lockable in two queues:\n" +
|
||||
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
|
||||
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
|
||||
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async lockClaimRequestV2(
|
||||
|
||||
@@ -41,12 +41,35 @@ export class ExpertInsurerController {
|
||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
||||
|
||||
@Get("branches")
|
||||
async getInsuranceBranches(@CurrentUser() insurer) {
|
||||
@ApiQuery({ name: "search", required: false, type: String })
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "isActive",
|
||||
required: false,
|
||||
description: "Filter active state (true/false)",
|
||||
})
|
||||
async getInsuranceBranches(
|
||||
@CurrentUser() insurer,
|
||||
@Query("search") search?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("isActive") isActive?: string,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insurer.clientKey,
|
||||
{ search, from, to, isActive },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +89,29 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put("branches/:branchId/status")
|
||||
@ApiParam({ name: "branchId" })
|
||||
@ApiQuery({ name: "isActive", type: Boolean })
|
||||
async setBranchStatus(
|
||||
@CurrentUser() insurer,
|
||||
@Param("branchId") branchId: string,
|
||||
@Query("isActive") isActive: string,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
const normalized = String(isActive).trim().toLowerCase();
|
||||
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
|
||||
throw new BadRequestException("isActive must be true/false");
|
||||
}
|
||||
const active = ["true", "1", "yes"].includes(normalized);
|
||||
return this.expertInsurerService.setBranchActive(
|
||||
insurer.clientKey,
|
||||
branchId,
|
||||
active,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("experts/blame")
|
||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||
async addBlameExpert(
|
||||
|
||||
@@ -29,6 +29,28 @@ import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-ac
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
||||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||
import { buildFileLink } from "src/helpers/urlCreator";
|
||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import {
|
||||
getClaimCarAngleCaptureBlob,
|
||||
getDamagedPartCaptureBlob,
|
||||
hasDamagedPartCapture,
|
||||
type ClaimCarAngleKey,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
catalogLikeKeyFromPart,
|
||||
normalizeDamageSelectedParts,
|
||||
type DamageSelectedPartV2,
|
||||
} from "src/helpers/outer-damage-parts";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertInsurerService {
|
||||
@@ -40,6 +62,11 @@ export class ExpertInsurerService {
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly branchDbService: BranchDbService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly blameVideoDbService: BlameVideoDbService,
|
||||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
||||
private readonly videoCaptureDbService: VideoCaptureDbService,
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly claimSignDbService: ClaimSignDbService,
|
||||
) {}
|
||||
|
||||
private getClientId(actorOrId: any): Types.ObjectId {
|
||||
@@ -225,6 +252,445 @@ export class ExpertInsurerService {
|
||||
};
|
||||
}
|
||||
|
||||
private serializeBlameExpertDecisionForInsurer(
|
||||
decision: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!decision || typeof decision !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const d = decision as Record<string, unknown>;
|
||||
const guilty = d.guiltyPartyId;
|
||||
const decidedBy = d.decidedByExpertId;
|
||||
const decidedAt = d.decidedAt;
|
||||
return {
|
||||
...d,
|
||||
guiltyPartyId:
|
||||
guilty != null && typeof (guilty as { toString?: () => string }).toString === "function"
|
||||
? String(guilty)
|
||||
: guilty,
|
||||
decidedByExpertId:
|
||||
decidedBy != null &&
|
||||
typeof (decidedBy as { toString?: () => string }).toString === "function"
|
||||
? String(decidedBy)
|
||||
: decidedBy,
|
||||
decidedAt:
|
||||
decidedAt instanceof Date
|
||||
? decidedAt.toISOString()
|
||||
: decidedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Same semantics as damage-expert claim detail `blameFileContext`. */
|
||||
private blameFileContextForInsurer(blame: Record<string, unknown> | null): {
|
||||
blameRequestType?: BlameRequestType;
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
blameStatus?: string;
|
||||
} {
|
||||
if (!blame?.type) return {};
|
||||
const blameRequestType = blame.type as BlameRequestType;
|
||||
const out: {
|
||||
blameRequestType?: BlameRequestType;
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
blameStatus?: string;
|
||||
} = { blameRequestType };
|
||||
out.blameStatus = blame.blameStatus as string | undefined;
|
||||
if (blameRequestType !== BlameRequestType.CAR_BODY) return out;
|
||||
|
||||
const parties = blame.parties;
|
||||
if (!Array.isArray(parties) || parties.length === 0) return out;
|
||||
|
||||
const first =
|
||||
(parties as any[]).find((p: any) => p?.role === PartyRole.FIRST) ?? (parties as any[])[0];
|
||||
const cbf = first?.carBodyFirstForm;
|
||||
delete out.blameStatus;
|
||||
if (cbf && typeof cbf === "object") {
|
||||
out.carBodyFirstForm = {
|
||||
...(typeof cbf.car === "boolean" ? { car: cbf.car } : {}),
|
||||
...(typeof cbf.object === "boolean" ? { object: cbf.object } : {}),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async clientNamesByClientIds(
|
||||
ids: Array<string | undefined | null>,
|
||||
): Promise<Record<string, { persian: string; english: string }>> {
|
||||
const unique = [
|
||||
...new Set(
|
||||
ids
|
||||
.filter((x): x is string => !!x && Types.ObjectId.isValid(String(x)))
|
||||
.map(String),
|
||||
),
|
||||
];
|
||||
const out: Record<string, { persian: string; english: string }> = {};
|
||||
await Promise.all(
|
||||
unique.map(async (id) => {
|
||||
const doc = await this.clientDbService.find({
|
||||
_id: new Types.ObjectId(id),
|
||||
});
|
||||
if (doc?.clientName) out[id] = doc.clientName;
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
private mapPersonForInsurer(
|
||||
person: any,
|
||||
clientNames: Record<string, { persian: string; english: string }>,
|
||||
) {
|
||||
if (!person || typeof person !== "object") return person;
|
||||
const cid =
|
||||
person.clientId?.toString?.() ?? (person.clientId != null ? String(person.clientId) : undefined);
|
||||
return {
|
||||
...person,
|
||||
userId: person.userId?.toString?.() ?? undefined,
|
||||
clientId: cid,
|
||||
clientName: cid ? clientNames[cid] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPartyForInsurerFull(
|
||||
party: any,
|
||||
clientNames: Record<string, { persian: string; english: string }>,
|
||||
) {
|
||||
if (!party || typeof party !== "object") return party;
|
||||
return {
|
||||
...party,
|
||||
person: this.mapPersonForInsurer(party.person, clientNames),
|
||||
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
|
||||
};
|
||||
}
|
||||
|
||||
/** Public URL for a stored relative path or pass-through if already absolute. */
|
||||
private linkFromPathOrUrl(stored: string | undefined): string | undefined {
|
||||
if (stored == null || stored === "") return undefined;
|
||||
const s = String(stored);
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
return buildFileLink(s);
|
||||
}
|
||||
|
||||
private enrichSignatureRecord(
|
||||
sig: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!sig || typeof sig !== "object") return sig;
|
||||
const fileUrl = sig.fileUrl != null ? String(sig.fileUrl) : undefined;
|
||||
const link = this.linkFromPathOrUrl(fileUrl);
|
||||
return { ...sig, link };
|
||||
}
|
||||
|
||||
private enrichPartiesConfirmationSignatures(parties: any[]): void {
|
||||
if (!Array.isArray(parties)) return;
|
||||
for (const p of parties) {
|
||||
const sig = p?.confirmation?.signature;
|
||||
if (sig && typeof sig === "object") {
|
||||
p.confirmation = {
|
||||
...p.confirmation,
|
||||
signature: this.enrichSignatureRecord(sig as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private insurerDamagedPartRowFromCapture(
|
||||
sp: DamageSelectedPartV2,
|
||||
index: number,
|
||||
damagedPartsDataExpert: unknown,
|
||||
selectedNormExpert: DamageSelectedPartV2[],
|
||||
) {
|
||||
const ck = sp.catalogKey ?? catalogLikeKeyFromPart(sp);
|
||||
const cap = getDamagedPartCaptureBlob(
|
||||
damagedPartsDataExpert,
|
||||
ck,
|
||||
selectedNormExpert,
|
||||
) as { url?: string; path?: string; fileName?: string } | undefined;
|
||||
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
|
||||
const link = url;
|
||||
return {
|
||||
index,
|
||||
id: sp.id,
|
||||
name: sp.name,
|
||||
side: sp.side,
|
||||
label_fa: sp.label_fa,
|
||||
catalogKey: sp.catalogKey,
|
||||
captured: hasDamagedPartCapture(damagedPartsDataExpert, ck, selectedNormExpert),
|
||||
path: cap?.path,
|
||||
fileName: cap?.fileName,
|
||||
url,
|
||||
link,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeDamageForInsurerDetail(damage: unknown): unknown {
|
||||
if (!damage || typeof damage !== "object") return damage;
|
||||
const d = { ...(damage as Record<string, unknown>) };
|
||||
delete d.selectedOuterParts;
|
||||
delete d.selectedPartIds;
|
||||
return d;
|
||||
}
|
||||
|
||||
private stripCarTypeFromPartyVehicles(parties: any[]): any[] {
|
||||
if (!Array.isArray(parties)) return parties;
|
||||
return parties.map((p) => {
|
||||
const v = p?.vehicle;
|
||||
if (!v || typeof v !== "object") return p;
|
||||
const { carType: _omit, ...rest } = v as Record<string, unknown>;
|
||||
const nextV = Object.keys(rest).length ? rest : undefined;
|
||||
return { ...p, vehicle: nextV };
|
||||
});
|
||||
}
|
||||
|
||||
private async claimSignLinkFromId(signDetailId: unknown): Promise<string | undefined> {
|
||||
if (signDetailId == null || signDetailId === "") return undefined;
|
||||
const id = String(signDetailId);
|
||||
if (!Types.ObjectId.isValid(id)) return undefined;
|
||||
const doc = await this.claimSignDbService.findOne({
|
||||
_id: new Types.ObjectId(id),
|
||||
});
|
||||
const path = (doc as any)?.path;
|
||||
return path ? buildFileLink(String(path)) : undefined;
|
||||
}
|
||||
|
||||
private async enrichClaimEvaluationForInsurer(
|
||||
evaluation: Record<string, unknown> | undefined,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
if (!evaluation || typeof evaluation !== "object") return evaluation;
|
||||
const ev = JSON.parse(JSON.stringify(evaluation)) as Record<string, unknown>;
|
||||
|
||||
const enrichReply = async (reply: any) => {
|
||||
if (!reply || typeof reply !== "object") return;
|
||||
const uc = reply.userComment;
|
||||
if (uc?.signDetailId != null) {
|
||||
const signLink = await this.claimSignLinkFromId(uc.signDetailId);
|
||||
reply.userComment = { ...uc, signLink };
|
||||
}
|
||||
};
|
||||
await enrichReply(ev.damageExpertReply);
|
||||
await enrichReply(ev.damageExpertReplyFinal);
|
||||
|
||||
const enrichApproval = async (key: string) => {
|
||||
const o = ev[key] as Record<string, unknown> | undefined;
|
||||
if (o?.signDetailId != null) {
|
||||
const signLink = await this.claimSignLinkFromId(o.signDetailId);
|
||||
ev[key] = { ...o, signLink };
|
||||
}
|
||||
};
|
||||
await enrichApproval("ownerInsurerApproval");
|
||||
await enrichApproval("ownerPricedPartsApproval");
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
private async enrichBlamePartyEvidenceUrls(parties: any[]): Promise<void> {
|
||||
if (!Array.isArray(parties)) return;
|
||||
for (const party of parties) {
|
||||
if (!party?.evidence) continue;
|
||||
const evidence = party.evidence as Record<string, unknown>;
|
||||
|
||||
if (evidence.videoId) {
|
||||
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId));
|
||||
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
||||
}
|
||||
|
||||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
||||
const voiceUrls: string[] = [];
|
||||
for (const voiceId of evidence.voices) {
|
||||
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId));
|
||||
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
|
||||
}
|
||||
evidence.voiceUrls = voiceUrls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async buildInsurerBlameDetail(
|
||||
blameLean: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const doc: Record<string, unknown> = { ...blameLean };
|
||||
const parties = (Array.isArray(doc.parties) ? doc.parties : []) as any[];
|
||||
await this.enrichBlamePartyEvidenceUrls(parties);
|
||||
doc.parties = enrichBlamePartiesForAgreementView(doc);
|
||||
|
||||
const mergedParties = doc.parties as any[];
|
||||
const clientIds = mergedParties.map((p) => p?.person?.clientId?.toString?.()).filter(Boolean);
|
||||
const clientNames = await this.clientNamesByClientIds(clientIds);
|
||||
doc.parties = mergedParties.map((p) => this.mapPartyForInsurerFull(p, clientNames));
|
||||
this.enrichPartiesConfirmationSignatures(doc.parties as any[]);
|
||||
|
||||
if (doc.expert && typeof doc.expert === "object") {
|
||||
const ex = { ...(doc.expert as Record<string, unknown>) };
|
||||
const serialized = this.serializeBlameExpertDecisionForInsurer(ex.decision);
|
||||
if (serialized !== undefined) ex.decision = serialized;
|
||||
doc.expert = ex;
|
||||
}
|
||||
|
||||
const createdAt = doc.createdAt
|
||||
? new Date(doc.createdAt as string | number | Date)
|
||||
: new Date();
|
||||
const updatedAt = doc.updatedAt
|
||||
? new Date(doc.updatedAt as string | number | Date)
|
||||
: new Date();
|
||||
const [cd, ct] = toJalaliDateAndTime(createdAt);
|
||||
const [ud, ut] = toJalaliDateAndTime(updatedAt);
|
||||
doc.createdAtFormatted = `${cd} ${ct}`;
|
||||
doc.updatedAtFormatted = `${ud} ${ut}`;
|
||||
|
||||
delete doc.history;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async buildInsurerClaimDetail(
|
||||
claim: Record<string, unknown>,
|
||||
blameForContext: Record<string, unknown> | null,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const requiredDocs = claim.requiredDocuments as any;
|
||||
const requiredDocumentsStatus: Record<string, any> = {};
|
||||
if (requiredDocs) {
|
||||
const keys =
|
||||
requiredDocs instanceof Map
|
||||
? Array.from(requiredDocs.keys())
|
||||
: Object.keys(requiredDocs);
|
||||
for (const k of keys as string[]) {
|
||||
const row = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||||
requiredDocumentsStatus[k] = {
|
||||
uploaded: !!row?.uploaded,
|
||||
fileId: row?.fileId?.toString?.(),
|
||||
fileName: row?.fileName,
|
||||
fileUrl: row?.filePath ? buildFileLink(row.filePath) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const carAnglesData = (claim.media as any)?.carAngles;
|
||||
const damagedPartsDataForAngles = (claim.media as any)?.damagedParts;
|
||||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||||
for (const k of ["front", "back", "left", "right"] as ClaimCarAngleKey[]) {
|
||||
const cap = getClaimCarAngleCaptureBlob(
|
||||
carAnglesData,
|
||||
damagedPartsDataForAngles,
|
||||
k,
|
||||
) as { url?: string; path?: string } | undefined;
|
||||
carAngles[k] = {
|
||||
captured: !!cap,
|
||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const carTypeExpert = (claim.vehicle as any)?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNormExpert = normalizeDamageSelectedParts(
|
||||
(claim.damage as any)?.selectedParts,
|
||||
carTypeExpert,
|
||||
(claim.damage as any)?.selectedOuterParts,
|
||||
);
|
||||
const damagedPartsDataExpert = (claim.media as any)?.damagedParts;
|
||||
const damagedParts = selectedNormExpert.map((sp, index) =>
|
||||
this.insurerDamagedPartRowFromCapture(
|
||||
sp,
|
||||
index,
|
||||
damagedPartsDataExpert,
|
||||
selectedNormExpert,
|
||||
),
|
||||
);
|
||||
const selectedPartsNormalized = selectedNormExpert.map((sp, index) =>
|
||||
this.insurerDamagedPartRowFromCapture(
|
||||
sp,
|
||||
index,
|
||||
damagedPartsDataExpert,
|
||||
selectedNormExpert,
|
||||
),
|
||||
);
|
||||
|
||||
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
|
||||
let videoCapture:
|
||||
| { id: string; fileName?: string; path?: string; url?: string }
|
||||
| undefined;
|
||||
if (videoCaptureIdRaw) {
|
||||
const vc = (await this.videoCaptureDbService.findById(
|
||||
String(videoCaptureIdRaw),
|
||||
)) as any;
|
||||
if (vc) {
|
||||
videoCapture = {
|
||||
id: String(vc._id ?? videoCaptureIdRaw),
|
||||
fileName: vc.fileName,
|
||||
path: vc.path,
|
||||
url: vc.path ? buildFileLink(vc.path) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const evaluation = claim.evaluation as Record<string, unknown> | undefined;
|
||||
const owner = claim.owner as any;
|
||||
const ownerOut = owner
|
||||
? {
|
||||
...owner,
|
||||
userId: owner.userId?.toString?.(),
|
||||
userClientKey: owner.userClientKey?.toString?.(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let snapshotOut = claim.snapshot as any;
|
||||
if (snapshotOut && Array.isArray(snapshotOut.parties)) {
|
||||
const snapIds = snapshotOut.parties
|
||||
.map((p: any) => p?.person?.clientId?.toString?.())
|
||||
.filter(Boolean);
|
||||
const snapNames = await this.clientNamesByClientIds(snapIds);
|
||||
const snapParties = this.stripCarTypeFromPartyVehicles(
|
||||
snapshotOut.parties.map((p: any) =>
|
||||
this.mapPartyForInsurerFull(p, snapNames),
|
||||
),
|
||||
);
|
||||
this.enrichPartiesConfirmationSignatures(snapParties);
|
||||
snapshotOut = {
|
||||
...snapshotOut,
|
||||
parties: snapParties,
|
||||
};
|
||||
}
|
||||
|
||||
const evaluationEnriched = await this.enrichClaimEvaluationForInsurer(evaluation);
|
||||
const vehicleSanitized = claim.vehicle
|
||||
? this.sanitizeVehicleInquiry(claim.vehicle as any)
|
||||
: undefined;
|
||||
const vehicleOut = vehicleSanitized
|
||||
? (() => {
|
||||
const { carType: _omit, ...rest } = vehicleSanitized as Record<string, unknown>;
|
||||
return rest;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
claimRequestId: (claim._id as any)?.toString?.(),
|
||||
publicId: claim.publicId,
|
||||
status: claim.status,
|
||||
claimStatus: claim.claimStatus,
|
||||
blameDocumentResendPending: claim.blameDocumentResendPending,
|
||||
workflow: claim.workflow,
|
||||
owner: ownerOut,
|
||||
vehicle: vehicleOut,
|
||||
blameRequestId: (claim as any).blameRequestId?.toString?.(),
|
||||
blameRequestNo: claim.blameRequestNo,
|
||||
blameFileContext: blameForContext
|
||||
? this.blameFileContextForInsurer(blameForContext)
|
||||
: undefined,
|
||||
money: claim.money,
|
||||
damage: this.sanitizeDamageForInsurerDetail(claim.damage),
|
||||
media: claim.media,
|
||||
requiredDocuments:
|
||||
Object.keys(requiredDocumentsStatus).length > 0
|
||||
? requiredDocumentsStatus
|
||||
: undefined,
|
||||
carAngles,
|
||||
damagedParts,
|
||||
selectedPartsNormalized,
|
||||
videoCapture,
|
||||
evaluation: evaluationEnriched,
|
||||
userRating: claim.userRating,
|
||||
insurerRating: (evaluationEnriched as any)?.rating,
|
||||
snapshot: snapshotOut,
|
||||
createdAt: claim.createdAt,
|
||||
updatedAt: claim.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private getCombinedFileScore(file: any): number | null {
|
||||
const insurerRating = file?.rating;
|
||||
const userRating = file?.userRating;
|
||||
@@ -655,6 +1121,7 @@ export class ExpertInsurerService {
|
||||
}
|
||||
|
||||
const id = this.getClientId(insurerId);
|
||||
const idStr = String(id);
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
@@ -667,12 +1134,31 @@ export class ExpertInsurerService {
|
||||
throw new NotFoundException("File not found for this publicId");
|
||||
}
|
||||
|
||||
const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : [];
|
||||
const firstParty = parties.find((p: any) => p?.role === "FIRST");
|
||||
const secondParty = parties.find((p: any) => p?.role === "SECOND");
|
||||
const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties)
|
||||
? (claim as any).snapshot.parties
|
||||
: undefined;
|
||||
let blameFull: Record<string, unknown> | null = null;
|
||||
if (blame) {
|
||||
blameFull = (await this.blameRequestDbService.findByIdWithoutHistory(
|
||||
String((blame as any)._id),
|
||||
)) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
let claimFull: Record<string, unknown> | null = null;
|
||||
if (claim) {
|
||||
const rows = (await this.claimCaseDbService.find(
|
||||
{ _id: new Types.ObjectId(String((claim as any)._id)) },
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
claimFull = rows[0] ?? null;
|
||||
}
|
||||
|
||||
let blameForClaimContext: Record<string, unknown> | null = blameFull;
|
||||
if (!blameForClaimContext && claimFull?.blameRequestId) {
|
||||
const linked = (await this.blameRequestDbService.findByIdWithoutHistory(
|
||||
String(claimFull.blameRequestId),
|
||||
)) as Record<string, unknown> | null;
|
||||
if (linked && blameCaseTouchesClient(linked, idStr)) {
|
||||
blameForClaimContext = linked;
|
||||
}
|
||||
}
|
||||
|
||||
const overview = {
|
||||
publicId,
|
||||
@@ -681,97 +1167,21 @@ export class ExpertInsurerService {
|
||||
(claim as any)?.requestNo ||
|
||||
(blame as any)?.requestNumber ||
|
||||
(claim as any)?.requestNumber,
|
||||
type: (blame as any)?.type,
|
||||
type: (blame as any)?.type ?? (blameFull as any)?.type,
|
||||
hasClaim: !!claim,
|
||||
blameStatus: (blame as any)?.status,
|
||||
blameStatus: (blame as any)?.status ?? (blameFull as any)?.status,
|
||||
claimStatus: (claim as any)?.status,
|
||||
claimReviewStatus: (claim as any)?.claimStatus,
|
||||
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
|
||||
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
|
||||
};
|
||||
|
||||
const blameDetails = blame
|
||||
? {
|
||||
requestId:
|
||||
(blame as any)?._id?.toString?.() || String((blame as any)?._id || ""),
|
||||
requestNo:
|
||||
(blame as any).requestNo || String((blame as any).requestNumber || ""),
|
||||
status: (blame as any).status,
|
||||
blameStatus: (blame as any).blameStatus,
|
||||
workflow: (blame as any).workflow,
|
||||
parties: {
|
||||
first: firstParty
|
||||
? {
|
||||
fullName: firstParty?.person?.fullName,
|
||||
phoneNumber: firstParty?.person?.phoneNumber,
|
||||
role: firstParty?.role,
|
||||
vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle),
|
||||
}
|
||||
: undefined,
|
||||
second: secondParty
|
||||
? {
|
||||
fullName: secondParty?.person?.fullName,
|
||||
phoneNumber: secondParty?.person?.phoneNumber,
|
||||
role: secondParty?.role,
|
||||
vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
expert: (blame as any).expert
|
||||
? {
|
||||
assignedExpertId: (blame as any).expert?.assignedExpertId,
|
||||
decision: (blame as any).expert?.decision,
|
||||
resend: (blame as any).expert?.resend,
|
||||
rating: (blame as any).expert?.rating,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: (blame as any).createdAt,
|
||||
updatedAt: (blame as any).updatedAt,
|
||||
}
|
||||
const blameDetails = blameFull
|
||||
? await this.buildInsurerBlameDetail(blameFull)
|
||||
: undefined;
|
||||
|
||||
// Keep claim section claim-specific to avoid duplicating shared data already in blame/overview.
|
||||
const claimDetails = claim
|
||||
? {
|
||||
requestId:
|
||||
(claim as any)?._id?.toString?.() || String((claim as any)?._id || ""),
|
||||
requestNo:
|
||||
(claim as any).requestNo || String((claim as any).requestNumber || ""),
|
||||
status: (claim as any).status,
|
||||
claimStatus: (claim as any).claimStatus,
|
||||
owner: (claim as any).owner,
|
||||
vehicle: (claim as any).vehicle,
|
||||
money: (claim as any).money,
|
||||
damage: (claim as any).damage,
|
||||
media: (claim as any).media,
|
||||
requiredDocuments: (claim as any).requiredDocuments,
|
||||
snapshot: (claim as any).snapshot
|
||||
? {
|
||||
...(claim as any).snapshot,
|
||||
parties: claimSnapshotParties?.map((p: any) =>
|
||||
this.sanitizePartyForInsurerView(p),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
workflow: {
|
||||
currentStep: (claim as any).currentStep,
|
||||
nextStep: (claim as any).nextStep,
|
||||
},
|
||||
evaluation: {
|
||||
damageExpertReply: (claim as any).damageExpertReply,
|
||||
damageExpertReplyFinal: (claim as any).damageExpertReplyFinal,
|
||||
damageExpertResend: (claim as any).damageExpertResend,
|
||||
objection: (claim as any).objection,
|
||||
priceDrop: (claim as any).priceDrop,
|
||||
visitLocation: (claim as any).visitLocation,
|
||||
factors: (claim as any).evaluation?.factors,
|
||||
},
|
||||
userResendDocuments: (claim as any).userResendDocuments,
|
||||
userRating: (claim as any).userRating,
|
||||
insurerRating: (claim as any).rating,
|
||||
createdAt: (claim as any).createdAt,
|
||||
updatedAt: (claim as any).updatedAt,
|
||||
}
|
||||
const claimDetails = claimFull
|
||||
? await this.buildInsurerClaimDetail(claimFull, blameForClaimContext)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@@ -797,12 +1207,66 @@ export class ExpertInsurerService {
|
||||
|
||||
const newBranch = await this.branchDbService.create({
|
||||
...branchDto,
|
||||
isActive: branchDto.isActive ?? true,
|
||||
activityStartDate: branchDto.activityStartDate
|
||||
? new Date(branchDto.activityStartDate)
|
||||
: undefined,
|
||||
clientKey: clientId,
|
||||
});
|
||||
|
||||
return newBranch;
|
||||
}
|
||||
|
||||
private parseOptionalBoolean(value?: string): boolean | undefined {
|
||||
if (value == null || value === "") return undefined;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["true", "1", "yes"].includes(normalized)) return true;
|
||||
if (["false", "0", "no"].includes(normalized)) return false;
|
||||
throw new BadRequestException("Invalid boolean value for 'isActive'");
|
||||
}
|
||||
|
||||
private buildHandlingBranchStatusSets() {
|
||||
const blameInHandling = new Set<string>([
|
||||
CaseStatus.OPEN,
|
||||
CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||
CaseStatus.WAITING_FOR_EXPERT,
|
||||
CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
||||
CaseStatus.WAITING_FOR_SIGNATURES,
|
||||
]);
|
||||
const claimInHandling = new Set<string>([
|
||||
ClaimCaseStatus.CREATED,
|
||||
ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||
ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN,
|
||||
ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING,
|
||||
ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING,
|
||||
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||||
]);
|
||||
return { blameInHandling, claimInHandling };
|
||||
}
|
||||
|
||||
async setBranchActive(
|
||||
clientKey: string,
|
||||
branchId: string,
|
||||
isActive: boolean,
|
||||
) {
|
||||
const clientId = this.getClientId(clientKey);
|
||||
await this.assertBranchBelongsToClient(branchId, clientId);
|
||||
const updated = await this.branchDbService.findByIdAndUpdate(branchId, {
|
||||
$set: { isActive },
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException("Branch not found");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async assertBranchBelongsToClient(
|
||||
branchId: string,
|
||||
clientId: Types.ObjectId,
|
||||
@@ -1035,8 +1499,108 @@ export class ExpertInsurerService {
|
||||
};
|
||||
}
|
||||
|
||||
async retrieveInsuranceBranches(insuranceId: string) {
|
||||
return this.branchDbService.findAll(insuranceId);
|
||||
async retrieveInsuranceBranches(
|
||||
insuranceId: string,
|
||||
opts?: {
|
||||
search?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
isActive?: string;
|
||||
},
|
||||
) {
|
||||
const clientObjectId = this.getClientId(insuranceId);
|
||||
const { fromDate, toDate } = this.parseDateRange(opts?.from, opts?.to);
|
||||
const isActiveFilter = this.parseOptionalBoolean(opts?.isActive);
|
||||
const branches = await this.branchDbService.findAllWithFilters(String(clientObjectId), {
|
||||
search: opts?.search,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
isActive: isActiveFilter,
|
||||
});
|
||||
|
||||
const branchIdSet = new Set(branches.map((b: any) => String(b._id)));
|
||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||||
this.expertDbService.findAll(ckFilter as never),
|
||||
this.damageExpertDbService.findAll(ckFilter as never),
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
]);
|
||||
|
||||
const activeExpertsByBranch = new Map<string, Set<string>>();
|
||||
const expertBranchById = new Map<string, string>();
|
||||
const claimExpertBranchById = new Map<string, string>();
|
||||
for (const e of experts as any[]) {
|
||||
const bid = e?.branchId ? String(e.branchId) : "";
|
||||
if (!bid || !branchIdSet.has(bid)) continue;
|
||||
const eid = String(e?._id || "");
|
||||
if (!eid) continue;
|
||||
expertBranchById.set(eid, bid);
|
||||
if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set());
|
||||
activeExpertsByBranch.get(bid)!.add(eid);
|
||||
}
|
||||
for (const e of damageExperts as any[]) {
|
||||
const bid = e?.branchId ? String(e.branchId) : "";
|
||||
if (!bid || !branchIdSet.has(bid)) continue;
|
||||
const eid = String(e?._id || "");
|
||||
if (!eid) continue;
|
||||
claimExpertBranchById.set(eid, bid);
|
||||
if (!activeExpertsByBranch.has(bid)) activeExpertsByBranch.set(bid, new Set());
|
||||
activeExpertsByBranch.get(bid)!.add(eid);
|
||||
}
|
||||
|
||||
const completedByBranch = new Map<string, Set<string>>();
|
||||
const handlingByBranch = new Map<string, Set<string>>();
|
||||
const { blameInHandling, claimInHandling } = this.buildHandlingBranchStatusSets();
|
||||
const addPublicId = (map: Map<string, Set<string>>, branchId: string, fileKey: string) => {
|
||||
if (!map.has(branchId)) map.set(branchId, new Set());
|
||||
map.get(branchId)!.add(fileKey);
|
||||
};
|
||||
|
||||
for (const b of blameFiles as any[]) {
|
||||
const expertId = String(b?.expert?.assignedExpertId || "");
|
||||
if (!expertId) continue;
|
||||
const bid = expertBranchById.get(expertId);
|
||||
if (!bid) continue;
|
||||
const fileKey = String(b?.publicId || b?._id || "");
|
||||
if (!fileKey) continue;
|
||||
const status = String(b?.status || "");
|
||||
if (status === CaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey);
|
||||
if (blameInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey);
|
||||
}
|
||||
|
||||
for (const c of claimFiles as any[]) {
|
||||
const expertId = String(this.claimDamageExpertActorId(c) || "");
|
||||
if (!expertId) continue;
|
||||
const bid = claimExpertBranchById.get(expertId);
|
||||
if (!bid) continue;
|
||||
const fileKey = String(c?.publicId || c?._id || "");
|
||||
if (!fileKey) continue;
|
||||
const status = String(c?.status || "");
|
||||
if (status === ClaimCaseStatus.COMPLETED) addPublicId(completedByBranch, bid, fileKey);
|
||||
if (claimInHandling.has(status)) addPublicId(handlingByBranch, bid, fileKey);
|
||||
}
|
||||
|
||||
const list = branches.map((branch: any) => {
|
||||
const bid = String(branch._id);
|
||||
return {
|
||||
...branch,
|
||||
totalActiveExperts: activeExpertsByBranch.get(bid)?.size ?? 0,
|
||||
totalFinishedFiles: completedByBranch.get(bid)?.size ?? 0,
|
||||
totalFilesInHandling: handlingByBranch.get(bid)?.size ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
total: list.length,
|
||||
filters: {
|
||||
search: opts?.search ?? null,
|
||||
from: fromDate ?? null,
|
||||
to: toDate ?? null,
|
||||
isActive: isActiveFilter ?? null,
|
||||
},
|
||||
branches: list,
|
||||
};
|
||||
}
|
||||
|
||||
private parseDateRange(from?: string, to?: string) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as jMoment from "jalali-moment";
|
||||
|
||||
/**
|
||||
* Converts an ISO date string or Date to Jalali (Persian) date and time.
|
||||
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
|
||||
@@ -12,6 +14,66 @@ export function toJalaliDateAndTime(
|
||||
return [dateStr, timeStr];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Jalali (Persian) date in any of the common shapes used across
|
||||
* this codebase to a Gregorian `YYYY-MM-DD` string.
|
||||
*
|
||||
* Accepted input shapes:
|
||||
* - number `13770624` (packed `YYYYMMDD`)
|
||||
* - string `"13770624"` / `"1377-06-24"` / `"1377/06/24"` / `"1377/6/24"`
|
||||
* - already-Gregorian `YYYY-MM-DD` strings are returned untouched as long as
|
||||
* they parse (we treat them as Gregorian only when the year is clearly
|
||||
* Gregorian, i.e. >= 1900). Otherwise the year is treated as Jalali.
|
||||
*
|
||||
* Returns `null` when the input cannot be parsed into a valid Jalali date.
|
||||
*/
|
||||
export function jalaliToGregorianDate(
|
||||
input: string | number | null | undefined,
|
||||
): string | null {
|
||||
if (input === null || input === undefined) return null;
|
||||
|
||||
const raw =
|
||||
typeof input === "number" ? String(input) : String(input).trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
|
||||
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
|
||||
if (separated) {
|
||||
year = parseInt(separated[1], 10);
|
||||
month = parseInt(separated[2], 10);
|
||||
day = parseInt(separated[3], 10);
|
||||
} else {
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length === 8) {
|
||||
year = parseInt(digits.slice(0, 4), 10);
|
||||
month = parseInt(digits.slice(4, 6), 10);
|
||||
day = parseInt(digits.slice(6, 8), 10);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!year || !month || !day) return null;
|
||||
|
||||
// If the year already looks Gregorian (>= 1900), assume the caller already
|
||||
// sent a Gregorian date and just normalise the formatting.
|
||||
if (year >= 1900) {
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
const m = jMoment(`${year}-${mm}-${dd}`, "YYYY-MM-DD");
|
||||
return m.isValid() ? m.format("YYYY-MM-DD") : null;
|
||||
}
|
||||
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
const jm = jMoment(`${year}-${mm}-${dd}`, "jYYYY-jMM-jDD");
|
||||
if (!jm.isValid()) return null;
|
||||
return jm.format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Date to Jalali date string (e.g. 1404/11/24).
|
||||
*/
|
||||
|
||||
@@ -869,7 +869,77 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: personal inquiry is not part of Tejarat block inquiry flow.
|
||||
// ---- External inquiry 2: personal identity check (insurer/driver nationalCode + birthDate) ----
|
||||
// The form sends the birthday as a Jalali date in any of these shapes:
|
||||
// - number 13770624 (packed YYYYMMDD)
|
||||
// - string "1377-06-24" / "1377/06/24" / "13770624"
|
||||
// We forward it as-is; sandHubService.getPersonalInquiry() handles the
|
||||
// Jalali → Gregorian conversion required by the external API.
|
||||
const personalNationalCode =
|
||||
body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
|
||||
const personalBirthDate: number | string | null =
|
||||
(body.insurerBirthday ?? body.driverBirthday ?? null) as
|
||||
| number
|
||||
| string
|
||||
| null;
|
||||
if (
|
||||
!personalNationalCode ||
|
||||
personalBirthDate === null ||
|
||||
personalBirthDate === undefined ||
|
||||
String(personalBirthDate).trim() === ""
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Valid nationalCode and birthDate are required for personal inquiry.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
||||
personalNationalCode,
|
||||
personalBirthDate,
|
||||
);
|
||||
this.logger.log(
|
||||
`[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(
|
||||
personalInquiry,
|
||||
)}`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
|
||||
);
|
||||
throw new HttpException(
|
||||
"Personal identity inquiry failed",
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- External inquiry 3: driving license check (insurerLicense + nationalCode) ----
|
||||
// const licenseNationalCode = body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
|
||||
// const licenseNumber = body.insurerLicense;
|
||||
// if (!licenseNationalCode || !licenseNumber) {
|
||||
// throw new BadRequestException(
|
||||
// "nationalCode and insurerLicense are required for driving license inquiry.",
|
||||
// );
|
||||
// }
|
||||
// try {
|
||||
// const licenseInquiry = await this.sandHubService.getDrivingLicenseInfo(
|
||||
// licenseNationalCode,
|
||||
// licenseNumber,
|
||||
// );
|
||||
// this.logger.log(
|
||||
// `[SANDHUB] license inquiry success request=${req._id} nationalCode=${licenseNationalCode}: ${JSON.stringify(
|
||||
// licenseInquiry,
|
||||
// )}`,
|
||||
// );
|
||||
// } catch (err: any) {
|
||||
// this.logger.error(
|
||||
// `[SANDHUB] license inquiry failed request=${req._id} nationalCode=${licenseNationalCode}: ${err?.message || err}`,
|
||||
// );
|
||||
// throw new HttpException(
|
||||
// "Driving license inquiry failed",
|
||||
// HttpStatus.BAD_REQUEST,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Find client by company code
|
||||
const clientName = inquiryMapped?.CompanyName;
|
||||
@@ -1359,6 +1429,7 @@ export class RequestManagementService {
|
||||
);
|
||||
await this.smsOrchestrationService.sendInviteLink(
|
||||
secondPartyPhone,
|
||||
req.publicId,
|
||||
url,
|
||||
);
|
||||
return {
|
||||
@@ -1407,7 +1478,7 @@ export class RequestManagementService {
|
||||
frontendRoute,
|
||||
requestId,
|
||||
);
|
||||
await this.smsOrchestrationService.sendInviteLink(phoneNumber, url);
|
||||
await this.smsOrchestrationService.sendInviteLink(phoneNumber, req.publicId, url);
|
||||
|
||||
return {
|
||||
requestId: req._id,
|
||||
@@ -2329,7 +2400,7 @@ export class RequestManagementService {
|
||||
frontendRoutes,
|
||||
requestId,
|
||||
);
|
||||
await this.smsOrchestrationService.sendInviteLink(phoneNumber, URL);
|
||||
await this.smsOrchestrationService.sendInviteLink(phoneNumber, request.publicId, URL);
|
||||
|
||||
return { url: URL };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import axios from "axios";
|
||||
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
|
||||
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
|
||||
import { SandHubDetailDto } from "./dto/sand-hub.dto";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
|
||||
@Injectable()
|
||||
export class SandHubService {
|
||||
@@ -473,13 +474,29 @@ export class SandHubService {
|
||||
}
|
||||
}
|
||||
|
||||
async getPersonalInquiry(nationalCode: string, birthDate: number) {
|
||||
/**
|
||||
* Personal identity check against the Tejarat hub. The upstream gateway only
|
||||
* accepts a Gregorian birthdate in `YYYY-MM-DD` form, but every caller in
|
||||
* this codebase has the value as a Jalali date (number `13770624` or string
|
||||
* `"1377-06-24"`/`"1377/06/24"`). We convert here so callers don't have to.
|
||||
*/
|
||||
async getPersonalInquiry(
|
||||
nationalCode: string,
|
||||
birthDate: number | string,
|
||||
) {
|
||||
try {
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/personal-inquiry/asia`;
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/personal-inquiry/tejarat-no`;
|
||||
|
||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||
if (!gregorianBirthdate) {
|
||||
throw new BadRequestException(
|
||||
`Invalid birth date for personal inquiry: ${birthDate}. Expected a Jalali date (e.g. 13770624 or "1377-06-24").`,
|
||||
);
|
||||
}
|
||||
|
||||
const requestPayload = {
|
||||
nin: nationalCode,
|
||||
birthDate: birthDate,
|
||||
persianBirthDate: "",
|
||||
nationalCode,
|
||||
birthdate: gregorianBirthdate,
|
||||
};
|
||||
|
||||
const response = await this.makeSandHubRequest(
|
||||
@@ -494,6 +511,12 @@ export class SandHubService {
|
||||
}
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof NotFoundException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Error in finding personal inquiry: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -577,12 +600,11 @@ export class SandHubService {
|
||||
|
||||
async getShebaValidation(nationalId: string, shebaId: string) {
|
||||
try {
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba`;
|
||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba/sheba-tejaratno`;
|
||||
const requestPayload = {
|
||||
accountOwnerType: "1",
|
||||
nationalId: nationalId,
|
||||
legalId: "",
|
||||
shebaId: shebaId,
|
||||
AccountOwnerType: "1",
|
||||
NationalId: nationalId,
|
||||
ShebaId: shebaId,
|
||||
};
|
||||
|
||||
this.logger.log(`Validating Sheba ID for national code: ${nationalId}`);
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
// import crypto from "crypto"
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
export class OtpGeneratorService {
|
||||
createDigit(digits: number): string {
|
||||
const max = Math.pow(10, digits);
|
||||
const randomBytes = crypto.randomBytes(Math.ceil(digits / 2));
|
||||
const randomNumber = parseInt(randomBytes.toString("hex"), 16) % max;
|
||||
return randomNumber.toString().padStart(digits, "0");
|
||||
}
|
||||
|
||||
create() {
|
||||
return this.createDigit(5);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
|
||||
import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
|
||||
import { SmsGatewayModule } from "./provider/sms-gateway.module";
|
||||
import { OtpGeneratorService } from "./otp-generator.service";
|
||||
import { SmsOrchestrationService } from "./sms-orchestration.service";
|
||||
|
||||
@Module({
|
||||
@@ -10,7 +11,7 @@ import { SmsOrchestrationService } from "./sms-orchestration.service";
|
||||
SmsGatewayModule,
|
||||
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
|
||||
],
|
||||
providers: [SmsTextDbService, SmsOrchestrationService],
|
||||
exports: [SmsOrchestrationService],
|
||||
providers: [SmsTextDbService, SmsOrchestrationService, OtpGeneratorService],
|
||||
exports: [SmsOrchestrationService, OtpGeneratorService],
|
||||
})
|
||||
export class SmsOrchestrationModule {}
|
||||
|
||||
@@ -50,11 +50,12 @@ export class SmsOrchestrationService implements OnModuleInit {
|
||||
return `${process.env.URL}/caseClaim?token=${claimRequestId}`;
|
||||
}
|
||||
|
||||
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
|
||||
async sendInviteLink(phoneNumber: string, publicId: string, link: string): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template: "yara724-invite-link",
|
||||
receptor: phoneNumber,
|
||||
token: link,
|
||||
token: publicId,
|
||||
token2: link,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"سوئیچ استارت": false,
|
||||
"کلید شیشه بالابر": false,
|
||||
"سوئیچ قفل فرمان": false,
|
||||
"ترموستات": false,
|
||||
"تهویه": false,
|
||||
"موتور": false,
|
||||
"کف کابین": false,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { OtpModule } from "src/utils/otp/otp.module";
|
||||
import { ExpertDbService } from "./entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
|
||||
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
|
||||
@@ -44,7 +43,6 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
||||
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
|
||||
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
|
||||
]),
|
||||
OtpModule,
|
||||
HashModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OtpService } from "./otp.service";
|
||||
|
||||
@Module({
|
||||
providers: [OtpService],
|
||||
exports: [OtpService],
|
||||
})
|
||||
export class OtpModule {}
|
||||
Reference in New Issue
Block a user