Compare commits

..

22 Commits

Author SHA1 Message Date
07c4e5126a Merge pull request 'Fixed bugs' (#64) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#64
2026-05-10 17:01:23 +03:30
SepehrYahyaee
010846acd9 Fixed bugs 2026-05-10 17:00:41 +03:30
SepehrYahyaee
cc926d4668 deprecated some old APIs + added examples for login 2026-05-10 14:29:32 +03:30
fdb75d52f7 Merge pull request 'YARA-884' (#63) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#63
2026-05-10 14:16:05 +03:30
SepehrYahyaee
186f6c5837 YARA-884 2026-05-10 14:15:38 +03:30
SepehrYahyaee
3fb90cf1c9 YARA-886 2026-05-10 14:04:11 +03:30
5e5ad0e1b3 Merge pull request 'Added sign links and data to expert-claim API' (#62) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#62
2026-05-10 12:42:37 +03:30
SepehrYahyaee
82bb232d28 Added sign links and data to expert-claim API 2026-05-10 12:42:24 +03:30
45392ad268 Merge pull request 'YARA-877' (#61) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#61
2026-05-10 11:44:07 +03:30
SepehrYahyaee
9c62dc4d3a YARA-877 2026-05-10 11:43:28 +03:30
d1bc64bb6d Merge pull request 'Fixed sheba inquiry' (#60) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#60
2026-05-09 17:51:58 +03:30
SepehrYahyaee
be58b7e47e Fixed sheba inquiry 2026-05-09 17:51:31 +03:30
78e86e5745 Merge pull request 'Fixed external API call to personal inquiry' (#59) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#59
2026-05-09 16:17:57 +03:30
SepehrYahyaee
fe163419c0 Fixed personal inquiry 2026-05-09 16:17:28 +03:30
SepehrYahyaee
eb648d8a87 Fixed personal inquiry 2026-05-09 16:17:09 +03:30
b856cb59f9 Merge pull request 'YARA-732' (#58) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#58
2026-05-09 14:00:40 +03:30
SepehrYahyaee
a52b7a0a72 Added catalog endpoints to claim expert panel 2026-05-09 14:00:02 +03:30
SepehrYahyaee
7998649a89 Added external APIs inquiries 2026-05-09 13:48:19 +03:30
SepehrYahyaee
74c91c73b6 Moved OTP to SMS module 2026-05-09 12:36:38 +03:30
SepehrYahyaee
9e2cec5bc3 YARA-732 2026-05-09 12:09:17 +03:30
e95cb4c255 Merge pull request 'Added extra fields for insurer' (#57) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#57
2026-05-09 10:14:27 +03:30
SepehrYahyaee
c1a54baaf0 Added extra fields for insurer 2026-05-09 09:59:01 +03:30
26 changed files with 1677 additions and 242 deletions

View File

@@ -11,6 +11,7 @@ import {
import { import {
ApiBody, ApiBody,
ApiAcceptedResponse, ApiAcceptedResponse,
ApiOperation,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
ApiBearerAuth, ApiBearerAuth,
@@ -39,13 +40,33 @@ import { CurrentUser } from "src/decorators/user.decorator";
export class ActorAuthController { export class ActorAuthController {
constructor(private readonly actorAuthService: ActorAuthService) {} 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") @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 }) @ApiBody({ type: GenuineRegisterDto })
async registerGenuine(@Body() body: GenuineRegisterDto) { async registerGenuine(@Body() body: GenuineRegisterDto) {
return await this.actorAuthService.genuineRegister(body); 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") @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 }) @ApiBody({ type: LegalRegisterDto })
async registerLegal(@Body() body: LegalRegisterDto) { async registerLegal(@Body() body: LegalRegisterDto) {
return await this.actorAuthService.legalRegister(body); return await this.actorAuthService.legalRegister(body);
@@ -76,9 +97,44 @@ export class ActorAuthController {
@UseGuards(LocalActorAuthGuard) @UseGuards(LocalActorAuthGuard)
@Post("login") @Post("login")
@Roles() @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({ @ApiBody({
type: LoginActorDto, 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() @ApiAcceptedResponse()
async login(@Body() body, @Req() req, @ClientKey() client) { async login(@Body() body, @Req() req, @ClientKey() client) {

View File

@@ -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 { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.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[]) { function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -51,7 +51,7 @@ export class ActorAuthService {
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used // private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService, private readonly otpService: OtpGeneratorService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller

View File

@@ -10,10 +10,10 @@ import {
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { LoginDtoRs } from "src/auth/dto/user/login.dto"; 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 { 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 { 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 // TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable() @Injectable()
@@ -24,7 +24,7 @@ export class UserAuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly hashService: HashService, private readonly hashService: HashService,
private readonly otpCreator: OtpService, private readonly otpCreator: OtpGeneratorService,
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
) {} ) {}

View File

@@ -11,7 +11,6 @@ import { ClientModule } from "src/client/client.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
// import { MailModule } from "src/utils/mail/mail.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"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
@Module({ @Module({
@@ -20,7 +19,6 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,
OtpModule,
PassportModule, PassportModule,
SmsOrchestrationModule, SmsOrchestrationModule,
JwtModule.register({ JwtModule.register({

View File

@@ -98,6 +98,7 @@ import { JwtModule } from "@nestjs/jwt";
DamageImageDbService, DamageImageDbService,
VideoCaptureDbService, VideoCaptureDbService,
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimSignDbService,
], ],
}) })
export class ClaimRequestManagementModule {} export class ClaimRequestManagementModule {}

View File

@@ -68,6 +68,7 @@ import { PublicIdService } from "src/utils/public-id/public-id.service";
import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { ImageRequiredModel } from "./entites/schema/image-required.schema";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; 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 { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { SandHubService } from "src/sand-hub/sand-hub.service";
import { import {
ExpertFileActivityType, ExpertFileActivityType,
ExpertFileKind, 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 { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
import { RoleEnum } from "src/Types&Enums/role.enum"; 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 { UserRatingDto } from "./dto/user-rating.dto";
import { import {
canFinalizeExpertResend, canFinalizeExpertResend,
@@ -111,12 +113,24 @@ import {
catalogItemToSelectedPart, catalogItemToSelectedPart,
catalogLikeKeyFromPart, catalogLikeKeyFromPart,
coerceDamagedPartsMediaToArray, coerceDamagedPartsMediaToArray,
type DamageSelectedPartV2,
migrateExpertReplyCarPartDamageToUnified, migrateExpertReplyCarPartDamageToUnified,
normalizeDamageSelectedParts, normalizeDamageSelectedParts,
partIdentityKey, partIdentityKey,
resolvePartCaptureIndex, resolvePartCaptureIndex,
} from "src/helpers/outer-damage-parts"; } 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() @Injectable()
export class ClaimRequestManagementService { export class ClaimRequestManagementService {
private readonly logger = new Logger(ClaimRequestManagementService.name); private readonly logger = new Logger(ClaimRequestManagementService.name);
@@ -158,13 +172,188 @@ export class ClaimRequestManagementService {
private readonly branchDbService: BranchDbService, private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly publicIdService: PublicIdService, 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> { private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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) { if (carType) {
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({ return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
...p, ...p,
@@ -178,10 +367,7 @@ export class ClaimRequestManagementService {
out.push({ ...p, carType: t }); out.push({ ...p, carType: t });
} }
} }
return out.sort((a, b) => { return out;
if (a.carType === b.carType) return a.id - b.id;
return String(a.carType).localeCompare(String(b.carType));
});
} }
private userDamageDetail(blRequest: RequestManagementModel) { private userDamageDetail(blRequest: RequestManagementModel) {
@@ -4008,17 +4194,17 @@ export class ClaimRequestManagementService {
); );
} }
// At most two non-top sides allowed // DISABLED: At most two non-top sides allowed
const sideSet = new Set( // const sideSet = new Set(
selectedItems // selectedItems
.map((p) => p.side) // .map((p) => p.side)
.filter((s) => s !== "top"), // .filter((s) => s !== "top"),
); // );
if (sideSet.size > 2) { // if (sideSet.size > 2) {
throw new BadRequestException( // throw new BadRequestException(
`At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`, // `At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`,
); // );
} // }
const selectedPartDocs = selectedItems.map((p) => const selectedPartDocs = selectedItems.map((p) =>
catalogItemToSelectedPart(p, catalog), 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 = { const updatePayload: any = {
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined, 'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
'money.sheba': shebaNumber, 'money.sheba': shebaNumber,
@@ -4325,12 +4527,14 @@ export class ClaimRequestManagementService {
throw new ForbiddenException('Only the claim owner can view capture requirements'); 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 selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const catalogForType = const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType] selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType] ? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsCatalogV2(); : this.getOuterPartsRawCatalog();
const catalogByKey = new Map<string, OuterPartCatalogItem>(); const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) { for (const item of catalogForType) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item); if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
@@ -4366,6 +4570,7 @@ export class ClaimRequestManagementService {
label_en: doc.label_en, label_en: doc.label_en,
category: doc.category, category: doc.category,
uploaded: docData?.uploaded || false, uploaded: docData?.uploaded || false,
preferUploadDuringCapture: isCapturePhaseDamagedPartyDocKey(doc.key),
}; };
}); });
@@ -4584,11 +4789,24 @@ export class ClaimRequestManagementService {
const step = claimCase.workflow?.currentStep; const step = claimCase.workflow?.currentStep;
const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND; const isResendUpload = step === ClaimWorkflowStep.USER_EXPERT_RESEND;
if (!isResendUpload && step !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) { const isCapturePhaseDocUpload =
step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES &&
isCapturePhaseDamagedPartyDocKey(body.documentKey);
if (!isResendUpload) {
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES && !isCapturePhaseDocUpload) {
throw new BadRequestException( throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS} or ${ClaimWorkflowStep.USER_EXPERT_RESEND}, but current step is ${claimCase.workflow?.currentStep}`, `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 (isResendUpload) {
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) { if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
throw new BadRequestException( throw new BadRequestException(
@@ -4662,16 +4880,87 @@ export class ClaimRequestManagementService {
let allDocumentsUploaded = false; let allDocumentsUploaded = false;
let remaining = 0; let remaining = 0;
if (!isResendUpload) { // Will be true when this upload also completes capture-phase docs AND
// Check if all documents are uploaded (8 for CAR_BODY, 13 for THIRD_PARTY) // every angle + every selected damaged part has already been captured.
const totalDocsRequired = isCarBodyUpload ? 8 : 13; // In that case we advance the workflow exactly like `capturePartV2` does
const currentDocs = claimCase.requiredDocuments || new Map(); // when the user finishes the last capture — otherwise the user would be
const uploadedCount = // stuck in CAPTURE_PART_DAMAGES with all captures done but no more
(currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; // captures to upload to trigger the transition.
allDocumentsUploaded = uploadedCount >= totalDocsRequired; let captureStepCompletedOnThisUpload = false;
remaining = totalDocsRequired - uploadedCount;
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 all documents uploaded, user flow complete (captures were done earlier in v2)
if (allDocumentsUploaded) { if (allDocumentsUploaded) {
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
updateData["claimStatus"] = ClaimStatus.PENDING; updateData["claimStatus"] = ClaimStatus.PENDING;
@@ -4713,6 +5002,7 @@ export class ClaimRequestManagementService {
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS; ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
} }
} }
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
@@ -4737,7 +5027,13 @@ export class ClaimRequestManagementService {
}; };
} }
const message = allDocumentsUploaded 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." ? "All documents uploaded successfully. Your claim is now ready for damage expert review."
: `Document uploaded successfully. ${remaining} documents remaining.`; : `Document uploaded successfully. ${remaining} documents remaining.`;
@@ -4746,7 +5042,11 @@ export class ClaimRequestManagementService {
documentKey: body.documentKey, documentKey: body.documentKey,
fileUrl, fileUrl,
allDocumentsUploaded, allDocumentsUploaded,
currentStep: allDocumentsUploaded currentStep: isCapturePhaseDocUpload
? captureStepCompletedOnThisUpload
? ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES
: allDocumentsUploaded
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE ? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message, message,
@@ -4998,8 +5298,11 @@ export class ClaimRequestManagementService {
); );
}).length; }).length;
const capturePhaseDocsDone = this.capturePhaseDamagedPartyDocsComplete(updatedClaim);
const allCapturesComplete = const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedNormAfter.length; anglesCaptured >= 4 &&
partsCaptured >= selectedNormAfter.length &&
capturePhaseDocsDone;
if (allCapturesComplete) { if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -5019,18 +5322,23 @@ export class ClaimRequestManagementService {
metadata: { metadata: {
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES, stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
description: 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 = const remaining =
(4 - anglesCaptured) + (selectedNormAfter.length - partsCaptured); (4 - anglesCaptured) +
(selectedNormAfter.length - partsCaptured) +
captureDocsRemaining;
const message = allCapturesComplete const message = allCapturesComplete
? 'All captures complete. Please proceed to upload required documents.' ? '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 { return {
claimRequestId: claimCase._id.toString(), claimRequestId: claimCase._id.toString(),

View File

@@ -638,13 +638,13 @@ Optional: upload car green card file in the same step.
summary: "Get Capture Requirements (V2)", summary: "Get Capture Requirements (V2)",
description: ` description: `
**Get list of what needs to be captured:** **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) - Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts) - Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not). 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({ @ApiParam({

View File

@@ -35,6 +35,13 @@ export class RequiredDocumentItem {
enum: ['general', 'damaged_party', 'guilty_party'], enum: ['general', 'damaged_party', 'guilty_party'],
}) })
category: string; 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;
} }
/** /**

View File

@@ -140,19 +140,54 @@ export class SetClaimVehicleTypeV2Dto {
carType: ClaimVehicleTypeV2; 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 { export class OuterPartCatalogItemDto {
@ApiProperty() @ApiProperty({
description: "Static catalog id (unique across all car types)",
example: 102,
})
id: number; id: number;
@ApiProperty() @ApiProperty({
key: string; description: "Side-agnostic part name (matches stored part `name`)",
example: "backWheel",
})
name: string;
@ApiProperty() @ApiProperty({
titleFa: string; description: "Vehicle side / region",
enum: OuterPartSideV2,
example: "left",
})
side: string;
@ApiProperty({ enum: OuterPartSideV2 }) @ApiProperty({
side: OuterPartSideV2; 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; carType?: ClaimVehicleTypeV2;
} }

View File

@@ -1,5 +1,11 @@
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsNotEmpty, IsOptional } from "class-validator"; import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from "class-validator";
export class CreateBranchDto { export class CreateBranchDto {
@ApiProperty({ example: "شهرک غرب" }) @ApiProperty({ example: "شهرک غرب" })
@@ -31,4 +37,18 @@ export class CreateBranchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
phoneNumber?: string; 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;
} }

View File

@@ -25,7 +25,71 @@ export class BranchDbService {
async findAll(insuranceId: string): Promise<BranchModel[]> { async findAll(insuranceId: string): Promise<BranchModel[]> {
return await this.branchModel.find({ return await this.branchModel.find({
clientKey: new Types.ObjectId(insuranceId), 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> { async findById(id: string): Promise<BranchModel | null> {

View File

@@ -25,6 +25,12 @@ export class BranchModel {
@Prop() @Prop()
phoneNumber?: string; phoneNumber?: string;
@Prop({ type: Boolean, default: true })
isActive?: boolean;
@Prop({ type: Date })
activityStartDate?: Date;
} }
export const BranchSchema = SchemaFactory.createForClass(BranchModel); export const BranchSchema = SchemaFactory.createForClass(BranchModel);

View File

@@ -120,11 +120,32 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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?: { evaluation?: {
damageExpertReply?: unknown; damageExpertReply?: unknown;
damageExpertReplyFinal?: 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({ @ApiPropertyOptional({

View File

@@ -16,6 +16,7 @@ import { distance as stringDistance } from "fastest-levenshtein"; // حتما ن
import { Types } from "mongoose"; import { Types } from "mongoose";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import { 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 { 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 { 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 { ClientDbService } from "src/client/entities/db-service/client.db.service";
@@ -216,8 +217,75 @@ export class ExpertClaimService {
private readonly smsOrchestrationService: SmsOrchestrationService, private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService, 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. */ /** Load immutable profile fields from `damage-expert` for the acting expert. */
private async snapshotDamageExpert(actorId: string) { private async snapshotDamageExpert(actorId: string) {
const doc = await this.damageExpertDbService.findById(actorId); const doc = await this.damageExpertDbService.findById(actorId);
@@ -2210,14 +2278,22 @@ export class ExpertClaimService {
* *
* Validations: * Validations:
* - Claim must exist * - Claim must exist
* - Status must be WAITING_FOR_DAMAGE_EXPERT * - Status must be either:
* - Must not already be locked by another expert * • `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: * On success:
* - Sets workflow.locked = true, workflow.lockedBy = actor * - Always sets `workflow.locked=true`, `workflow.lockedBy=actor`,
* - Sets status = EXPERT_REVIEWING * `workflow.lockedAt/expiredAt`, and snapshots pre-lock queue state.
* - Sets claimStatus = UNDER_REVIEW * - For the **damage review** path: status → `EXPERT_REVIEWING`,
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT * `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) { async lockClaimRequestV2(claimRequestId: string, actor: any) {
requireActorClientKey(actor); requireActorClientKey(actor);
@@ -2230,7 +2306,11 @@ export class ExpertClaimService {
assertClaimCaseForTenant(claim, actor); 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( throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`, `Claim is not available for locking. Current status: ${claim.status}`,
); );
@@ -2257,9 +2337,8 @@ export class ExpertClaimService {
const lockAt = new Date(); const lockAt = new Date();
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs); const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { // Common lock fields written in both branches.
status: ClaimCaseStatus.EXPERT_REVIEWING, const baseLockUpdate: Record<string, unknown> = {
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.locked': true, 'workflow.locked': true,
'workflow.lockedAt': lockAt, 'workflow.lockedAt': lockAt,
'workflow.expiredAt': expiredAt, 'workflow.expiredAt': expiredAt,
@@ -2284,7 +2363,6 @@ export class ExpertClaimService {
} }
: {}), : {}),
}, },
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: { $push: {
history: { history: {
type: "CLAIM_LOCKED", type: "CLAIM_LOCKED",
@@ -2294,10 +2372,28 @@ export class ExpertClaimService {
actorType: "damage_expert", actorType: "damage_expert",
}, },
timestamp: new Date(), 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({ await this.recordClaimExpertActivity({
expertId: String(actor.sub), expertId: String(actor.sub),
@@ -2523,7 +2619,7 @@ export class ExpertClaimService {
} }
// Price cap validation // Price cap validation
const PRICE_CAP = 30_000_000; const PRICE_CAP = 53_000_000;
let totalPrice = 0; let totalPrice = 0;
for (const part of reply.parts || []) { for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0')); const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
@@ -3473,6 +3569,46 @@ export class ExpertClaimService {
) )
: undefined; : 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 { return {
claimRequestId: claim._id.toString(), claimRequestId: claim._id.toString(),
publicId: claim.publicId, publicId: claim.publicId,
@@ -3511,12 +3647,9 @@ export class ExpertClaimService {
carAngles, carAngles,
damagedParts, damagedParts,
awaitingFactorValidation: isFactorValidationPending, awaitingFactorValidation: isFactorValidationPending,
evaluation: isFactorValidationPending evaluation: evaluationForApi as
? { | ClaimDetailV2ResponseDto["evaluation"]
damageExpertReply: (claim as any).evaluation?.damageExpertReply, | undefined,
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
}
: undefined,
objection, objection,
videoCapture, videoCapture,
blameCase: blameCaseForApi, blameCase: blameCaseForApi,

View File

@@ -6,6 +6,7 @@ import {
ApiParam, ApiParam,
ApiPropertyOptional, ApiPropertyOptional,
ApiQuery, ApiQuery,
ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator"; 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 { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto"; import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto"; 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 { class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' }) @ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@@ -32,7 +36,10 @@ class InPersonVisitV2Dto {
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT) @Roles(RoleEnum.DAMAGE_EXPERT)
export class ExpertClaimV2Controller { export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) { } constructor(
private readonly expertClaimService: ExpertClaimService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("report/status-counts") @Get("report/status-counts")
@ApiOperation({ @ApiOperation({
@@ -44,6 +51,26 @@ export class ExpertClaimV2Controller {
return await this.expertClaimService.getStatusReportBucketsV2(actor); 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") @Get("requests")
@ApiOperation({ @ApiOperation({
summary: "List available claim requests for damage expert", summary: "List available claim requests for damage expert",
@@ -72,7 +99,10 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Lock a claim request for review", summary: "Lock a claim request for review",
description: 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" }) @ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2( async lockClaimRequestV2(

View File

@@ -41,12 +41,35 @@ export class ExpertInsurerController {
constructor(private readonly expertInsurerService: ExpertInsurerService) {} constructor(private readonly expertInsurerService: ExpertInsurerService) {}
@Get("branches") @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) { if (!insurer) {
throw new UnauthorizedException("Could not identify the current user."); throw new UnauthorizedException("Could not identify the current user.");
} }
return await this.expertInsurerService.retrieveInsuranceBranches( return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey, 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") @Post("experts/blame")
@ApiBody({ type: CreateBlameExpertByInsurerDto }) @ApiBody({ type: CreateBlameExpertByInsurerDto })
async addBlameExpert( async addBlameExpert(

View File

@@ -29,6 +29,28 @@ import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-ac
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.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() @Injectable()
export class ExpertInsurerService { export class ExpertInsurerService {
@@ -40,6 +62,11 @@ export class ExpertInsurerService {
private readonly claimCaseDbService: ClaimCaseDbService, private readonly claimCaseDbService: ClaimCaseDbService,
private readonly branchDbService: BranchDbService, private readonly branchDbService: BranchDbService,
private readonly hashService: HashService, 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 { 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 { private getCombinedFileScore(file: any): number | null {
const insurerRating = file?.rating; const insurerRating = file?.rating;
const userRating = file?.userRating; const userRating = file?.userRating;
@@ -655,6 +1121,7 @@ export class ExpertInsurerService {
} }
const id = this.getClientId(insurerId); const id = this.getClientId(insurerId);
const idStr = String(id);
const [blameFiles, claimFiles] = await Promise.all([ const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id), this.getClientBlameFiles(id),
this.getClientClaimFiles(id), this.getClientClaimFiles(id),
@@ -667,12 +1134,31 @@ export class ExpertInsurerService {
throw new NotFoundException("File not found for this publicId"); throw new NotFoundException("File not found for this publicId");
} }
const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : []; let blameFull: Record<string, unknown> | null = null;
const firstParty = parties.find((p: any) => p?.role === "FIRST"); if (blame) {
const secondParty = parties.find((p: any) => p?.role === "SECOND"); blameFull = (await this.blameRequestDbService.findByIdWithoutHistory(
const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties) String((blame as any)._id),
? (claim as any).snapshot.parties )) as Record<string, unknown> | null;
: undefined; }
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 = { const overview = {
publicId, publicId,
@@ -681,97 +1167,21 @@ export class ExpertInsurerService {
(claim as any)?.requestNo || (claim as any)?.requestNo ||
(blame as any)?.requestNumber || (blame as any)?.requestNumber ||
(claim as any)?.requestNumber, (claim as any)?.requestNumber,
type: (blame as any)?.type, type: (blame as any)?.type ?? (blameFull as any)?.type,
hasClaim: !!claim, hasClaim: !!claim,
blameStatus: (blame as any)?.status, blameStatus: (blame as any)?.status ?? (blameFull as any)?.status,
claimStatus: (claim as any)?.status, claimStatus: (claim as any)?.status,
claimReviewStatus: (claim as any)?.claimStatus, claimReviewStatus: (claim as any)?.claimStatus,
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt, createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt, updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
}; };
const blameDetails = blame const blameDetails = blameFull
? { ? await this.buildInsurerBlameDetail(blameFull)
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,
}
: undefined; : undefined;
// Keep claim section claim-specific to avoid duplicating shared data already in blame/overview. const claimDetails = claimFull
const claimDetails = claim ? await this.buildInsurerClaimDetail(claimFull, blameForClaimContext)
? {
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,
}
: undefined; : undefined;
return { return {
@@ -797,12 +1207,66 @@ export class ExpertInsurerService {
const newBranch = await this.branchDbService.create({ const newBranch = await this.branchDbService.create({
...branchDto, ...branchDto,
isActive: branchDto.isActive ?? true,
activityStartDate: branchDto.activityStartDate
? new Date(branchDto.activityStartDate)
: undefined,
clientKey: clientId, clientKey: clientId,
}); });
return newBranch; 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( private async assertBranchBelongsToClient(
branchId: string, branchId: string,
clientId: Types.ObjectId, clientId: Types.ObjectId,
@@ -1035,8 +1499,108 @@ export class ExpertInsurerService {
}; };
} }
async retrieveInsuranceBranches(insuranceId: string) { async retrieveInsuranceBranches(
return this.branchDbService.findAll(insuranceId); 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) { private parseDateRange(from?: string, to?: string) {

View File

@@ -1,3 +1,5 @@
import * as jMoment from "jalali-moment";
/** /**
* Converts an ISO date string or Date to Jalali (Persian) date and time. * 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 * @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]; 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). * Converts a Date to Jalali date string (e.g. 1404/11/24).
*/ */

View File

@@ -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 // Find client by company code
const clientName = inquiryMapped?.CompanyName; const clientName = inquiryMapped?.CompanyName;
@@ -1359,6 +1429,7 @@ export class RequestManagementService {
); );
await this.smsOrchestrationService.sendInviteLink( await this.smsOrchestrationService.sendInviteLink(
secondPartyPhone, secondPartyPhone,
req.publicId,
url, url,
); );
return { return {
@@ -1407,7 +1478,7 @@ export class RequestManagementService {
frontendRoute, frontendRoute,
requestId, requestId,
); );
await this.smsOrchestrationService.sendInviteLink(phoneNumber, url); await this.smsOrchestrationService.sendInviteLink(phoneNumber, req.publicId, url);
return { return {
requestId: req._id, requestId: req._id,
@@ -2329,7 +2400,7 @@ export class RequestManagementService {
frontendRoutes, frontendRoutes,
requestId, requestId,
); );
await this.smsOrchestrationService.sendInviteLink(phoneNumber, URL); await this.smsOrchestrationService.sendInviteLink(phoneNumber, request.publicId, URL);
return { url: URL }; return { url: URL };
} }

View File

@@ -13,6 +13,7 @@ import axios from "axios";
import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service";
import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema"; import { SandHubModel } from "src/sand-hub/entity/schema/sand-hub.schema";
import { SandHubDetailDto } from "./dto/sand-hub.dto"; import { SandHubDetailDto } from "./dto/sand-hub.dto";
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
@Injectable() @Injectable()
export class SandHubService { 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 { 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 = { const requestPayload = {
nin: nationalCode, nationalCode,
birthDate: birthDate, birthdate: gregorianBirthdate,
persianBirthDate: "",
}; };
const response = await this.makeSandHubRequest( const response = await this.makeSandHubRequest(
@@ -494,6 +511,12 @@ export class SandHubService {
} }
return response.data; return response.data;
} catch (err) { } catch (err) {
if (
err instanceof BadRequestException ||
err instanceof NotFoundException
) {
throw err;
}
throw new Error(`Error in finding personal inquiry: ${err}`); throw new Error(`Error in finding personal inquiry: ${err}`);
} }
} }
@@ -577,12 +600,11 @@ export class SandHubService {
async getShebaValidation(nationalId: string, shebaId: string) { async getShebaValidation(nationalId: string, shebaId: string) {
try { try {
const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba`; const requestUrl = `${process.env.SANDHUB_BASE_URL}/sheba/sheba-tejaratno`;
const requestPayload = { const requestPayload = {
accountOwnerType: "1", AccountOwnerType: "1",
nationalId: nationalId, NationalId: nationalId,
legalId: "", ShebaId: shebaId,
shebaId: shebaId,
}; };
this.logger.log(`Validating Sheba ID for national code: ${nationalId}`); this.logger.log(`Validating Sheba ID for national code: ${nationalId}`);

View File

@@ -1,15 +1,15 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
// import crypto from "crypto"
const crypto = require("node:crypto"); const crypto = require("node:crypto");
@Injectable() @Injectable()
export class OtpService { export class OtpGeneratorService {
createDigit(digits: number): string { createDigit(digits: number): string {
const max = Math.pow(10, digits); const max = Math.pow(10, digits);
const randomBytes = crypto.randomBytes(Math.ceil(digits / 2)); const randomBytes = crypto.randomBytes(Math.ceil(digits / 2));
const randomNumber = parseInt(randomBytes.toString("hex"), 16) % max; const randomNumber = parseInt(randomBytes.toString("hex"), 16) % max;
return randomNumber.toString().padStart(digits, "0"); return randomNumber.toString().padStart(digits, "0");
} }
create() { create() {
return this.createDigit(5); return this.createDigit(5);
} }

View File

@@ -3,6 +3,7 @@ import { MongooseModule } from "@nestjs/mongoose";
import { SmsTextDbService } from "./entities/db-service/sms-text.db.service"; import { SmsTextDbService } from "./entities/db-service/sms-text.db.service";
import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema"; import { SmsText, SmsTextSchema } from "./entities/schema/sms-text.schema";
import { SmsGatewayModule } from "./provider/sms-gateway.module"; import { SmsGatewayModule } from "./provider/sms-gateway.module";
import { OtpGeneratorService } from "./otp-generator.service";
import { SmsOrchestrationService } from "./sms-orchestration.service"; import { SmsOrchestrationService } from "./sms-orchestration.service";
@Module({ @Module({
@@ -10,7 +11,7 @@ import { SmsOrchestrationService } from "./sms-orchestration.service";
SmsGatewayModule, SmsGatewayModule,
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]), MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
], ],
providers: [SmsTextDbService, SmsOrchestrationService], providers: [SmsTextDbService, SmsOrchestrationService, OtpGeneratorService],
exports: [SmsOrchestrationService], exports: [SmsOrchestrationService, OtpGeneratorService],
}) })
export class SmsOrchestrationModule {} export class SmsOrchestrationModule {}

View File

@@ -50,11 +50,12 @@ export class SmsOrchestrationService implements OnModuleInit {
return `${process.env.URL}/caseClaim?token=${claimRequestId}`; 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({ return this.sendTemplate({
template: "yara724-invite-link", template: "yara724-invite-link",
receptor: phoneNumber, receptor: phoneNumber,
token: link, token: publicId,
token2: link,
}); });
} }

View File

@@ -57,7 +57,6 @@
"سوئیچ استارت": false, "سوئیچ استارت": false,
"کلید شیشه بالابر": false, "کلید شیشه بالابر": false,
"سوئیچ قفل فرمان": false, "سوئیچ قفل فرمان": false,
"ترموستات": false,
"تهویه": false, "تهویه": false,
"موتور": false, "موتور": false,
"کف کابین": false, "کف کابین": false,

View File

@@ -3,7 +3,6 @@ import { MongooseModule } from "@nestjs/mongoose";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertModel } from "src/users/entities/schema/expert.schema"; import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { HashModule } from "src/utils/hash/hash.module"; 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 { ExpertDbService } from "./entities/db-service/expert.db.service";
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
import { InsurerExpertDbService } from "./entities/db-service/insurer-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: RegistrarModel.name, schema: RegistrarDbSchema },
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema }, { name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
]), ]),
OtpModule,
HashModule, HashModule,
], ],
controllers: [], controllers: [],

View File

@@ -1,8 +0,0 @@
import { Module } from "@nestjs/common";
import { OtpService } from "./otp.service";
@Module({
providers: [OtpService],
exports: [OtpService],
})
export class OtpModule {}