forked from Yara724/api
Compare commits
12 Commits
4bdb9fd469
...
b9eb0ab5bd
| Author | SHA1 | Date | |
|---|---|---|---|
| b9eb0ab5bd | |||
| 38f700c3d7 | |||
|
|
1a1d55bc2e | ||
| a2f1d5ea7f | |||
|
|
9a65071276 | ||
| 12d6fa4d73 | |||
|
|
640b240ada | ||
|
|
f8fbbb7ac6 | ||
|
|
0e2789c209 | ||
| 7661862564 | |||
|
|
a2143fe1c5 | ||
| 9afb079786 |
@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.SECRET}`,
|
||||||
});
|
});
|
||||||
if (payload.role !== RoleEnum.USER) {
|
|
||||||
console.log(
|
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
|
||||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
|
||||||
request.user.role,
|
|
||||||
);
|
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
request.user = payload;
|
request.user = payload;
|
||||||
request.identity = request.user;
|
request.identity = request.user;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ 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 { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
|
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -341,6 +343,166 @@ export class ClaimRequestManagementService {
|
|||||||
return trueItems;
|
return trueItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */
|
||||||
|
private carPartDamageSectionToRows(section: unknown): Record<string, unknown>[] {
|
||||||
|
if (section == null) return [];
|
||||||
|
if (Array.isArray(section)) {
|
||||||
|
return section.filter(
|
||||||
|
(row): row is Record<string, unknown> =>
|
||||||
|
!!row && typeof row === "object",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof section === "object") {
|
||||||
|
return [section as Record<string, unknown>];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private carPartDamageSectionHasTrue(section: unknown): boolean {
|
||||||
|
for (const row of this.carPartDamageSectionToRows(section)) {
|
||||||
|
for (const val of Object.values(row)) {
|
||||||
|
if (val === true) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At most two of left, right, front, back may contain any damaged part (true).
|
||||||
|
* `top` and claim `otherParts` are not subject to this rule.
|
||||||
|
*/
|
||||||
|
private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void {
|
||||||
|
const d = dto as unknown as Record<string, unknown>;
|
||||||
|
const quadrants = [
|
||||||
|
{ key: "left", section: d.left },
|
||||||
|
{ key: "right", section: d.right },
|
||||||
|
{ key: "front", section: d.front },
|
||||||
|
{ key: "back", section: d.back },
|
||||||
|
];
|
||||||
|
const used = quadrants.filter((q) =>
|
||||||
|
this.carPartDamageSectionHasTrue(q.section),
|
||||||
|
);
|
||||||
|
if (used.length > 2) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`At most two of the four sides (left, right, front, back) may include damaged parts. ` +
|
||||||
|
`Sides with selections: ${used.map((u) => u.key).join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts).
|
||||||
|
*/
|
||||||
|
private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const add = (slug: string | undefined) => {
|
||||||
|
if (slug) out.add(slug);
|
||||||
|
};
|
||||||
|
const walkMainOnSide = (
|
||||||
|
side: "left" | "right",
|
||||||
|
items: unknown,
|
||||||
|
map: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
for (const row of this.carPartDamageSectionToRows(items)) {
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
if (val !== true) continue;
|
||||||
|
const slug = map[`${side}_${key}`] ?? map[key];
|
||||||
|
add(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// MainParts on left / right (side-specific door / fender)
|
||||||
|
walkMainOnSide("left", (dto as any).left, {
|
||||||
|
left_frontDoor: "front_left_door",
|
||||||
|
left_backDoor: "rear_left_door",
|
||||||
|
left_frontFender: "front_left_fender",
|
||||||
|
left_backFender: "rear_left_fender",
|
||||||
|
frontDoor: "front_left_door",
|
||||||
|
backDoor: "rear_left_door",
|
||||||
|
frontFender: "front_left_fender",
|
||||||
|
backFender: "rear_left_fender",
|
||||||
|
});
|
||||||
|
walkMainOnSide("right", (dto as any).right, {
|
||||||
|
right_frontDoor: "front_right_door",
|
||||||
|
right_backDoor: "rear_right_door",
|
||||||
|
right_frontFender: "front_right_fender",
|
||||||
|
right_backFender: "rear_right_fender",
|
||||||
|
frontDoor: "front_right_door",
|
||||||
|
backDoor: "rear_right_door",
|
||||||
|
frontFender: "front_right_fender",
|
||||||
|
backFender: "rear_right_fender",
|
||||||
|
});
|
||||||
|
const walkSimple = (items: unknown, keyToSlug: Record<string, string>) => {
|
||||||
|
for (const row of this.carPartDamageSectionToRows(items)) {
|
||||||
|
for (const [key, val] of Object.entries(row)) {
|
||||||
|
if (val === true) add(keyToSlug[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkSimple((dto as any).front, {
|
||||||
|
frontBumper: "front_bumper",
|
||||||
|
carHood: "hood",
|
||||||
|
});
|
||||||
|
walkSimple((dto as any).back, {
|
||||||
|
backBumper: "rear_bumper",
|
||||||
|
carTrunk: "trunk",
|
||||||
|
});
|
||||||
|
walkSimple((dto as any).top, {
|
||||||
|
roof: "roof",
|
||||||
|
});
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */
|
||||||
|
private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] {
|
||||||
|
if (!raw || !Array.isArray(raw)) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === "string" && item.trim()) {
|
||||||
|
out.push(item.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
for (const [k, v] of Object.entries(item)) {
|
||||||
|
if (v === true && k) out.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertFieldExpertInPersonBlameForClaimCase(
|
||||||
|
claimCase: any,
|
||||||
|
expert: any,
|
||||||
|
): Promise<void> {
|
||||||
|
const blameRequestId = claimCase?.blameRequestId?.toString?.();
|
||||||
|
if (!blameRequestId) {
|
||||||
|
throw new BadRequestException("Claim case has no linked blame request.");
|
||||||
|
}
|
||||||
|
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||||||
|
if (!blameRequest) {
|
||||||
|
throw new NotFoundException("Blame request not found for this claim.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!blameRequest.expertInitiated ||
|
||||||
|
blameRequest.creationMethod !== CreationMethod.IN_PERSON
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This endpoint is only for expert-initiated IN_PERSON claim files.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!blameRequest.initiatedByFieldExpertId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Blame request is missing initiating field expert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only the initiating field expert can complete claim data for this file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setCarPartDamageAndImage(cl, trueItems) {
|
async setCarPartDamageAndImage(cl, trueItems) {
|
||||||
cl.carPartDamage = trueItems;
|
cl.carPartDamage = trueItems;
|
||||||
const jsonTrueItems = JSON.parse(JSON.stringify(trueItems));
|
const jsonTrueItems = JSON.parse(JSON.stringify(trueItems));
|
||||||
@@ -394,7 +556,9 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Field expert completes claim-needed data for expert-initiated IN_PERSON files.
|
* Field expert completes claim-needed data for expert-initiated IN_PERSON files.
|
||||||
* Only the initiating expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts.
|
* Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts.
|
||||||
|
*
|
||||||
|
* V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported.
|
||||||
*/
|
*/
|
||||||
async expertCompleteClaimData(
|
async expertCompleteClaimData(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
@@ -406,6 +570,16 @@ export class ClaimRequestManagementService {
|
|||||||
otherParts?: any[];
|
otherParts?: any[];
|
||||||
},
|
},
|
||||||
): Promise<{ success: boolean; claimRequestId: string }> {
|
): Promise<{ success: boolean; claimRequestId: string }> {
|
||||||
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (claimCase) {
|
||||||
|
return this.expertCompleteClaimDataForClaimCaseV2(
|
||||||
|
claimRequestId,
|
||||||
|
expert,
|
||||||
|
dto,
|
||||||
|
claimCase,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const claim = await this.claimDbService.findOne(claimRequestId);
|
const claim = await this.claimDbService.findOne(claimRequestId);
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim file not found");
|
throw new NotFoundException("Claim file not found");
|
||||||
@@ -427,6 +601,7 @@ export class ClaimRequestManagementService {
|
|||||||
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) {
|
||||||
throw new BadRequestException("Car part damage is already set.");
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
}
|
}
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||||||
const trueItems = this.findTrueItems(dto.carPartDamage);
|
const trueItems = this.findTrueItems(dto.carPartDamage);
|
||||||
const claimDoc = (await this.claimDbService.findOne(
|
const claimDoc = (await this.claimDbService.findOne(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -455,6 +630,178 @@ export class ClaimRequestManagementService {
|
|||||||
return { success: true, claimRequestId };
|
return { success: true, claimRequestId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only).
|
||||||
|
*/
|
||||||
|
private async expertCompleteClaimDataForClaimCaseV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
expert: any,
|
||||||
|
dto: {
|
||||||
|
carPartDamage?: CarDamagePartDto;
|
||||||
|
sheba?: string;
|
||||||
|
nationalCodeOfInsurer?: string;
|
||||||
|
otherParts?: any[];
|
||||||
|
},
|
||||||
|
claimCase: any,
|
||||||
|
): Promise<{ success: boolean; claimRequestId: string }> {
|
||||||
|
await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert);
|
||||||
|
|
||||||
|
let working = claimCase;
|
||||||
|
|
||||||
|
if (dto.carPartDamage) {
|
||||||
|
if (working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
working.damage?.selectedParts &&
|
||||||
|
working.damage.selectedParts.length > 0
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("Car part damage is already set.");
|
||||||
|
}
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage);
|
||||||
|
const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage);
|
||||||
|
if (!selectedParts.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"No outer damaged parts could be derived from carPartDamage. Check part selections.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
|
claimRequestId,
|
||||||
|
{
|
||||||
|
"damage.selectedParts": selectedParts,
|
||||||
|
status: ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
$push: {
|
||||||
|
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
history: {
|
||||||
|
type: "STEP_COMPLETED",
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(expert.sub),
|
||||||
|
actorName:
|
||||||
|
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||||||
|
"field_expert",
|
||||||
|
actorType: "field_expert",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
|
selectedParts,
|
||||||
|
description: "Field expert submitted outer damage parts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
throw new InternalServerErrorException("Failed to update claim case");
|
||||||
|
}
|
||||||
|
working = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBankOrOther =
|
||||||
|
dto.sheba != null ||
|
||||||
|
dto.nationalCodeOfInsurer != null ||
|
||||||
|
dto.otherParts != null;
|
||||||
|
if (hasBankOrOther) {
|
||||||
|
const fresh = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!fresh) throw new NotFoundException("Claim case not found");
|
||||||
|
if (fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSheba =
|
||||||
|
dto.sheba != null ? dto.sheba : fresh.money?.sheba;
|
||||||
|
const mergedNational =
|
||||||
|
dto.nationalCodeOfInsurer != null
|
||||||
|
? dto.nationalCodeOfInsurer
|
||||||
|
: fresh.money?.nationalCodeOfInsurer;
|
||||||
|
|
||||||
|
if (fresh.money?.sheba && dto.sheba != null && dto.sheba !== fresh.money.sheba) {
|
||||||
|
throw new ConflictException("Sheba is already set for this claim.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fresh.money?.nationalCodeOfInsurer &&
|
||||||
|
dto.nationalCodeOfInsurer != null &&
|
||||||
|
dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"National code of insurer is already set for this claim.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts);
|
||||||
|
const setPatch: Record<string, unknown> = {};
|
||||||
|
if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba;
|
||||||
|
if (dto.nationalCodeOfInsurer != null) {
|
||||||
|
setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer;
|
||||||
|
}
|
||||||
|
if (dto.otherParts != null) {
|
||||||
|
setPatch["damage.otherParts"] =
|
||||||
|
otherPartsArr.length > 0 ? otherPartsArr : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bankComplete =
|
||||||
|
mergedSheba &&
|
||||||
|
mergedNational &&
|
||||||
|
/^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) &&
|
||||||
|
/^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, ""));
|
||||||
|
|
||||||
|
if (bankComplete) {
|
||||||
|
const shebaNorm = String(mergedSheba).replace(/\s/g, "");
|
||||||
|
const nationalNorm = String(mergedNational).replace(/\s/g, "");
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
"money.sheba": shebaNorm,
|
||||||
|
"money.nationalCodeOfInsurer": nationalNorm,
|
||||||
|
...(dto.otherParts != null
|
||||||
|
? {
|
||||||
|
"damage.otherParts":
|
||||||
|
otherPartsArr.length > 0 ? otherPartsArr : [],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
|
$push: {
|
||||||
|
"workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
|
history: {
|
||||||
|
type: "STEP_COMPLETED",
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(expert.sub),
|
||||||
|
actorName:
|
||||||
|
`${expert.firstName || ""} ${expert.lastName || ""}`.trim() ||
|
||||||
|
"field_expert",
|
||||||
|
actorType: "field_expert",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
|
description:
|
||||||
|
"Field expert submitted bank info and optional other parts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (Object.keys(setPatch).length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: setPatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, claimRequestId };
|
||||||
|
}
|
||||||
|
|
||||||
async selectCarOtherPartDamage(
|
async selectCarOtherPartDamage(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
body: any, // Use your DTO for better type safety
|
body: any, // Use your DTO for better type safety
|
||||||
@@ -3349,7 +3696,23 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Convert selected parts to storage format (simple array of strings)
|
// 5. Convert selected parts to storage format (simple array of strings)
|
||||||
const selectedParts = body.selectedParts;
|
// Backward compatibility: some clients may still send legacy `carPartDamage` object.
|
||||||
|
let selectedParts = Array.isArray((body as any)?.selectedParts)
|
||||||
|
? ((body as any).selectedParts as string[])
|
||||||
|
: [];
|
||||||
|
if (selectedParts.length === 0 && (body as any)?.carPartDamage) {
|
||||||
|
this.assertCarPartDamageAtMostTwoOfFourSides(
|
||||||
|
(body as any).carPartDamage as CarDamagePartDto,
|
||||||
|
);
|
||||||
|
selectedParts = this.carDamagePartDtoToOuterPartSlugs(
|
||||||
|
(body as any).carPartDamage as CarDamagePartDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectedParts.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"selectedParts is required and must contain at least one item",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Update claim case with selected parts and move to next step
|
// 6. Update claim case with selected parts and move to next step
|
||||||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
@@ -3372,8 +3735,8 @@ export class ClaimRequestManagementService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
selectedParts: selectedParts,
|
selectedParts: selectedParts,
|
||||||
partsCount: selectedParts.length,
|
partsCount: selectedParts?.length,
|
||||||
description: `User selected ${selectedParts.length} damaged outer parts`,
|
description: `User selected ${selectedParts?.length} damaged outer parts`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3385,7 +3748,7 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Outer parts selected for claim ${claimRequestId}: ${selectedParts.length} parts`,
|
`Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. Return response
|
// 7. Return response
|
||||||
@@ -3431,6 +3794,7 @@ export class ClaimRequestManagementService {
|
|||||||
body: SelectOtherPartsV2Dto,
|
body: SelectOtherPartsV2Dto,
|
||||||
currentUserId: string,
|
currentUserId: string,
|
||||||
actor?: { sub: string; role?: string },
|
actor?: { sub: string; role?: string },
|
||||||
|
file?: Express.Multer.File,
|
||||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
// 1. Validate claim exists
|
// 1. Validate claim exists
|
||||||
@@ -3469,40 +3833,108 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Prepare data
|
// 5. Prepare data
|
||||||
const otherParts = body.otherParts || [];
|
// Backward compatibility aliases:
|
||||||
const shebaNumber = body.shebaNumber;
|
// - shebaNumber <- sheba
|
||||||
const nationalCode = body.nationalCodeOfOwner;
|
// - nationalCodeOfOwner <- nationalCodeOfInsurer
|
||||||
|
let otherParts = Array.isArray((body as any)?.otherParts)
|
||||||
|
? (body as any).otherParts
|
||||||
|
: [];
|
||||||
|
// Multipart clients may send otherParts as JSON string
|
||||||
|
if (!Array.isArray((body as any)?.otherParts) && typeof (body as any)?.otherParts === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse((body as any).otherParts);
|
||||||
|
otherParts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
otherParts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const shebaInput = String(
|
||||||
|
((body as any).shebaNumber ?? (body as any).sheba ?? "") as string,
|
||||||
|
)
|
||||||
|
.replace(/\s/g, "");
|
||||||
|
|
||||||
// 6. Update claim case with other parts, bank info and move to next step
|
const shebaDigits = shebaInput.replace(/^IR/i, "");
|
||||||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
const shebaNumber = `IR${shebaDigits}`;
|
||||||
claimRequestId,
|
const nationalCode = String(
|
||||||
{
|
((body as any).nationalCodeOfOwner ??
|
||||||
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
|
(body as any).nationalCodeOfInsurer ??
|
||||||
'money.sheba': shebaNumber,
|
"") as string,
|
||||||
'money.nationalCodeOfInsurer': nationalCode,
|
).replace(/\s/g, "");
|
||||||
'status': ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
if (!/^[0-9]{24}$/.test(shebaDigits)) {
|
||||||
'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
throw new BadRequestException(
|
||||||
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
"shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)",
|
||||||
$push: {
|
);
|
||||||
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
}
|
||||||
history: {
|
if (!/^[0-9]{10}$/.test(nationalCode)) {
|
||||||
type: 'STEP_COMPLETED',
|
throw new BadRequestException(
|
||||||
actor: {
|
"nationalCodeOfOwner is required and must be exactly 10 digits",
|
||||||
actorId: new Types.ObjectId(currentUserId),
|
);
|
||||||
actorName: claimCase.owner?.fullName || 'User',
|
}
|
||||||
actorType: 'user',
|
|
||||||
},
|
const updatePayload: any = {
|
||||||
timestamp: new Date(),
|
'damage.otherParts': otherParts.length > 0 ? otherParts : undefined,
|
||||||
metadata: {
|
'money.sheba': shebaNumber,
|
||||||
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
'money.nationalCodeOfInsurer': nationalCode,
|
||||||
otherParts: otherParts,
|
'status': ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||||
otherPartsCount: otherParts.length,
|
'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
hasBankInfo: true,
|
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
description: `User selected ${otherParts.length} other damaged parts and provided bank information`,
|
$push: {
|
||||||
},
|
'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
|
history: {
|
||||||
|
type: 'STEP_COMPLETED',
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || 'User',
|
||||||
|
actorType: 'user',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
|
otherParts: otherParts,
|
||||||
|
otherPartsCount: otherParts.length,
|
||||||
|
hasBankInfo: true,
|
||||||
|
hasGreenCardUpload: !!file,
|
||||||
|
description: `User selected ${otherParts.length} other damaged parts and provided bank information`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const existingGreen =
|
||||||
|
(claimCase.requiredDocuments as any)?.get?.(
|
||||||
|
ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||||
|
) ||
|
||||||
|
(claimCase.requiredDocuments as any)?.[
|
||||||
|
ClaimRequiredDocumentType.CAR_GREEN_CARD
|
||||||
|
];
|
||||||
|
if (existingGreen?.uploaded) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"car_green_card has already been uploaded for this claim",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const docRef = await this.claimRequiredDocumentDbService.create({
|
||||||
|
path: file.path,
|
||||||
|
fileName: file.filename,
|
||||||
|
claimId: new Types.ObjectId(claimRequestId),
|
||||||
|
documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
});
|
||||||
|
updatePayload[
|
||||||
|
`requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}`
|
||||||
|
] = {
|
||||||
|
fileId: docRef._id,
|
||||||
|
filePath: file.path,
|
||||||
|
fileName: file.filename,
|
||||||
|
uploaded: true,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Update claim case with other parts, bank info and optional green card file
|
||||||
|
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
|
claimRequestId,
|
||||||
|
updatePayload,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!updatedClaim) {
|
if (!updatedClaim) {
|
||||||
@@ -3514,8 +3946,8 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. Mask sensitive data for response
|
// 7. Mask sensitive data for response
|
||||||
const maskedSheba = shebaNumber.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3');
|
const maskedSheba = `IR${shebaDigits.slice(0, 4)}************${shebaDigits.slice(-4)}`;
|
||||||
const maskedNationalCode = nationalCode.replace(/^(.{2})(.*)(.{2})$/, '$1******$3');
|
const maskedNationalCode = `${nationalCode.slice(0, 2)}******${nationalCode.slice(-2)}`;
|
||||||
|
|
||||||
// 8. Return response
|
// 8. Return response
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -344,13 +344,14 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
**Validations:**
|
**Validations:**
|
||||||
- Claim must exist
|
- Claim must exist
|
||||||
- User must be the claim owner (damaged party from blame case)
|
- User must be the claim owner (damaged party from blame case)
|
||||||
- Current workflow step must be SELECT_OTHER_PARTS
|
- Current workflow step must be SELECT_OTHER_PARTS
|
||||||
- Bank information must not have been submitted previously
|
- Bank information must not have been submitted previously
|
||||||
- Sheba number must be exactly 24 digits
|
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||||
- National code must be exactly 10 digits
|
- National code must be exactly 10 digits
|
||||||
|
|
||||||
**Valid Other Parts (Optional):**
|
**Valid Other Parts (Optional):**
|
||||||
@@ -368,42 +369,38 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
description: "The claim case ID (MongoDB ObjectId)",
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
example: "507f1f77bcf86cd799439011",
|
example: "507f1f77bcf86cd799439011",
|
||||||
})
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-required-document",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `other-parts-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: SelectOtherPartsV2Dto,
|
description:
|
||||||
description: "Other parts selection and bank information",
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
|
||||||
examples: {
|
schema: {
|
||||||
example1: {
|
type: "object",
|
||||||
summary: "Only bank info (no other parts damaged)",
|
properties: {
|
||||||
value: {
|
otherParts: {
|
||||||
otherParts: [],
|
oneOf: [
|
||||||
shebaNumber: "123456789012345678901234",
|
{ type: "array", items: { type: "string" } },
|
||||||
nationalCodeOfOwner: "1234567890",
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
},
|
],
|
||||||
},
|
example: ["engine", "suspension"],
|
||||||
example2: {
|
|
||||||
summary: "Engine and suspension damage",
|
|
||||||
value: {
|
|
||||||
otherParts: ["engine", "suspension"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
example3: {
|
|
||||||
summary: "Multiple systems damaged",
|
|
||||||
value: {
|
|
||||||
otherParts: ["engine", "brake_system", "electrical", "headlight"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
example4: {
|
|
||||||
summary: "Lighting and glass damage",
|
|
||||||
value: {
|
|
||||||
otherParts: ["headlight", "taillight", "mirror", "glass"],
|
|
||||||
shebaNumber: "123456789012345678901234",
|
|
||||||
nationalCodeOfOwner: "1234567890",
|
|
||||||
},
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
},
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -431,6 +428,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() body: SelectOtherPartsV2Dto,
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.selectOtherPartsV2(
|
return await this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
@@ -438,6 +436,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
body,
|
body,
|
||||||
user.sub,
|
user.sub,
|
||||||
user,
|
user,
|
||||||
|
file,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
|
|||||||
@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
|
|||||||
otherParts?: OtherCarPart[];
|
otherParts?: OtherCarPart[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
|
description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
|
||||||
|
example: 'IR123456789012345678901234',
|
||||||
|
})
|
||||||
|
@IsNotEmpty({ message: 'sheba is required' })
|
||||||
|
@IsString({ message: 'sheba must be a string' })
|
||||||
|
sheba: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Legacy alias of sheba for backward compatibility',
|
||||||
example: '123456789012345678901234',
|
example: '123456789012345678901234',
|
||||||
pattern: '^[0-9]{24}$',
|
|
||||||
minLength: 24,
|
|
||||||
maxLength: 24,
|
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Sheba number is required' })
|
@IsOptional()
|
||||||
@IsString({ message: 'Sheba number must be a string' })
|
@IsString()
|
||||||
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
|
shebaNumber?: string;
|
||||||
@Matches(/^[0-9]{24}$/, {
|
|
||||||
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
|
|
||||||
})
|
|
||||||
shebaNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'National code of the owner - 10 digits',
|
description: 'National code of insurer/owner - 10 digits',
|
||||||
example: '1234567890',
|
example: '1234567890',
|
||||||
pattern: '^[0-9]{10}$',
|
pattern: '^[0-9]{10}$',
|
||||||
minLength: 10,
|
minLength: 10,
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'National code of owner is required' })
|
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
||||||
@IsString({ message: 'National code must be a string' })
|
@IsString({ message: 'National code must be a string' })
|
||||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||||
@Matches(/^[0-9]{10}$/, {
|
@Matches(/^[0-9]{10}$/, {
|
||||||
message: 'National code must contain exactly 10 digits',
|
message: 'National code must contain exactly 10 digits',
|
||||||
})
|
})
|
||||||
nationalCodeOfOwner: string;
|
nationalCodeOfInsurer: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Legacy alias for backward compatibility',
|
||||||
|
example: '1234567890',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalCodeOfOwner?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
assertBlameCaseForExpertTenant,
|
||||||
|
blameCaseAccessibleToExpert,
|
||||||
|
requireActorClientKey,
|
||||||
|
} from "src/helpers/tenant-scope";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -192,6 +197,7 @@ export class ExpertBlameService {
|
|||||||
*/
|
*/
|
||||||
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
||||||
try {
|
try {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const expertId = actor.sub;
|
const expertId = actor.sub;
|
||||||
|
|
||||||
// Fetch all DISAGREEMENT cases
|
// Fetch all DISAGREEMENT cases
|
||||||
@@ -203,10 +209,15 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Filter to show only:
|
// Filter to show only:
|
||||||
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
|
||||||
// 2. Requests decided by current expert
|
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
||||||
// 3. Expert-initiated: only the initiating field expert sees them
|
// 3. Requests decided by current expert
|
||||||
|
// 4. Expert-initiated: only the initiating field expert sees them
|
||||||
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
|
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
|
||||||
|
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const expertInitiated = doc.expertInitiated === true;
|
const expertInitiated = doc.expertInitiated === true;
|
||||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||||
|
|
||||||
@@ -625,100 +636,15 @@ export class ExpertBlameService {
|
|||||||
* Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence.
|
* Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence.
|
||||||
* Access control: Only allows viewing fresh requests or requests decided by current expert.
|
* Access control: Only allows viewing fresh requests or requests decided by current expert.
|
||||||
*/
|
*/
|
||||||
// async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
|
async findOneV2(requestId: string, actor: any): Promise<Record<string, unknown>> {
|
||||||
// try {
|
|
||||||
// const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
|
||||||
// if (!doc) {
|
|
||||||
// throw new NotFoundException("Request not found");
|
|
||||||
// }
|
|
||||||
// const type = doc.type as string;
|
|
||||||
// if (type === BlameRequestType.CAR_BODY) {
|
|
||||||
// throw new ForbiddenException(
|
|
||||||
// "CAR_BODY type requests are automatically handled and do not require expert review.",
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Access control: Expert-initiated files only visible to the initiating field expert
|
|
||||||
// const expertInitiated = doc.expertInitiated === true;
|
|
||||||
// const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
|
||||||
// if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
|
|
||||||
// throw new ForbiddenException(
|
|
||||||
// "Only the field expert who created this file can view and review it.",
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Allow if:
|
|
||||||
// // 1. No decision yet (fresh request)
|
|
||||||
// // 2. Decision made by current expert
|
|
||||||
// const decision = (doc.expert as any)?.decision;
|
|
||||||
// const decidedByExpertId = decision?.decidedByExpertId;
|
|
||||||
// if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
|
||||||
// throw new ForbiddenException(
|
|
||||||
// "You do not have permission to view this request. It has been handled by another expert.",
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const parties = (doc.parties ?? []) as Array<{
|
|
||||||
// evidence?: { videoId?: string; voices?: string[] };
|
|
||||||
// [key: string]: unknown;
|
|
||||||
// }>;
|
|
||||||
// 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;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date();
|
|
||||||
// const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date();
|
|
||||||
// const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
|
||||||
// const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
|
||||||
// doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
|
||||||
// doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
|
||||||
|
|
||||||
// // Exclude mapped inquiry vehicle for both parties from response
|
|
||||||
// for (const party of parties) {
|
|
||||||
// delete party.vehicle;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return doc;
|
|
||||||
// } catch (error) {
|
|
||||||
// if (error instanceof HttpException) throw error;
|
|
||||||
// this.logger.error(
|
|
||||||
// "findOneV2 failed",
|
|
||||||
// requestId,
|
|
||||||
// error instanceof Error ? error.stack : String(error),
|
|
||||||
// );
|
|
||||||
// throw new InternalServerErrorException(
|
|
||||||
// error instanceof Error ? error.message : "Failed to get blame case details",
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
|
|
||||||
try {
|
try {
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||||
if (!doc) throw new NotFoundException("Request not found");
|
if (!doc) {
|
||||||
|
throw new NotFoundException("Request not found");
|
||||||
|
}
|
||||||
|
assertBlameCaseForExpertTenant(doc, actor);
|
||||||
const type = doc.type as string;
|
const type = doc.type as string;
|
||||||
if (type === BlameRequestType.CAR_BODY) {
|
if (type === BlameRequestType.CAR_BODY) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -821,12 +747,35 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
|
|
||||||
async lockRequest(requestId: string, actorDetail) {
|
async lockRequest(requestId: string, actorDetail) {
|
||||||
|
const existing = await this.requestManagementDbService.findOne(requestId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException("Request not found");
|
||||||
|
}
|
||||||
|
if (existing.blameStatus === ReqBlameStatus.UserPending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Cannot lock request because blameStatus is UserPending",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocked = !!existing.lockFile;
|
||||||
|
const lockedByCurrent =
|
||||||
|
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||||||
|
const isLockExpired =
|
||||||
|
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime();
|
||||||
|
|
||||||
|
// Idempotent behavior: same expert can re-enter their locked file.
|
||||||
|
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||||||
|
return { _id: requestId, lock: true, message: "Already locked by you" };
|
||||||
|
}
|
||||||
|
if (isLocked && !lockedByCurrent && !isLockExpired) {
|
||||||
|
throw new BadRequestException("Request is locked by another expert");
|
||||||
|
}
|
||||||
|
|
||||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
|
||||||
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
_id: requestId,
|
_id: requestId,
|
||||||
lockFile: false,
|
|
||||||
blameStatus: { $ne: ReqBlameStatus.UserPending },
|
blameStatus: { $ne: ReqBlameStatus.UserPending },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -880,6 +829,8 @@ export class ExpertBlameService {
|
|||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actorDetail);
|
||||||
|
|
||||||
// Validate request is available for expert review
|
// Validate request is available for expert review
|
||||||
if (
|
if (
|
||||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
||||||
@@ -909,7 +860,9 @@ export class ExpertBlameService {
|
|||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
if (Date.now() < lockExpiryTime) {
|
if (Date.now() < lockExpiryTime) {
|
||||||
// Lock is still valid
|
// Lock is still valid
|
||||||
const lockedByActorId = String(request.workflow.lockedBy?.actorId);
|
const lockedByActorId = String(
|
||||||
|
request.workflow.lockedBy?.actorId ?? "",
|
||||||
|
);
|
||||||
if (lockedByActorId === actorDetail.sub) {
|
if (lockedByActorId === actorDetail.sub) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You have already locked this request",
|
"You have already locked this request",
|
||||||
@@ -968,7 +921,7 @@ export class ExpertBlameService {
|
|||||||
async resendRequestV2(
|
async resendRequestV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
resendDto: ResendRequestDto,
|
resendDto: ResendRequestDto,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
requestId: string;
|
requestId: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -981,12 +934,17 @@ export class ExpertBlameService {
|
|||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
// Validate request is locked by current expert
|
// Validate request is locked by current expert
|
||||||
if (!request.workflow?.locked) {
|
if (!request.workflow?.locked) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -1135,16 +1093,20 @@ export class ExpertBlameService {
|
|||||||
async replyRequestV2(
|
async replyRequestV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
reply: SubmitReplyDto,
|
reply: SubmitReplyDto,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<{ requestId: string; status: string }> {
|
): Promise<{ requestId: string; status: string }> {
|
||||||
try {
|
try {
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
// Validate no decision exists yet
|
// Validate no decision exists yet
|
||||||
if (request.expert?.decision) {
|
if (request.expert?.decision) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.findOneV2(id, actor.sub);
|
return await this.expertBlameService.findOneV2(id, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -73,7 +73,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.resendRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -93,7 +93,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.replyRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
|||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
|
import {
|
||||||
|
assertClaimCaseForTenant,
|
||||||
|
claimCaseTouchesClient,
|
||||||
|
requireActorClientKey,
|
||||||
|
} from "src/helpers/tenant-scope";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
||||||
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
||||||
@@ -319,8 +324,18 @@ export class ExpertClaimService {
|
|||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
if (!request) throw new BadRequestException("Claim request not found");
|
if (!request) throw new BadRequestException("Claim request not found");
|
||||||
|
|
||||||
if (request.lockFile) {
|
const isLocked = !!request.lockFile;
|
||||||
throw new BadRequestException("Claim request is locked");
|
const lockedByCurrent =
|
||||||
|
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||||||
|
const isLockExpired =
|
||||||
|
!!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime();
|
||||||
|
|
||||||
|
// Idempotent behavior: same expert can re-enter their locked file.
|
||||||
|
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||||||
|
return { _id: requestId, lock: true, message: "Already locked by you" };
|
||||||
|
}
|
||||||
|
if (isLocked && !lockedByCurrent && !isLockExpired) {
|
||||||
|
throw new BadRequestException("Claim request is locked by another expert");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.claimStatus === ReqClaimStatus.UserPending) {
|
if (request.claimStatus === ReqClaimStatus.UserPending) {
|
||||||
@@ -1602,13 +1617,15 @@ export class ExpertClaimService {
|
|||||||
async validateClaimFactorsV2(
|
async validateClaimFactorsV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
body: FactorValidationV2Dto,
|
body: FactorValidationV2Dto,
|
||||||
actor: { sub: string; fullName?: string },
|
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||||
) {
|
) {
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
|
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
|
||||||
claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
|
claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
|
||||||
@@ -1842,12 +1859,15 @@ export class ExpertClaimService {
|
|||||||
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
|
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
|
||||||
*/
|
*/
|
||||||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||||
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}`,
|
||||||
@@ -1913,6 +1933,8 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
@@ -1994,12 +2016,15 @@ export class ExpertClaimService {
|
|||||||
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
|
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
|
||||||
actor: any,
|
actor: any,
|
||||||
) {
|
) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in a reviewable state. Current status: ${claim.status}`,
|
`Claim is not in a reviewable state. Current status: ${claim.status}`,
|
||||||
@@ -2131,12 +2156,15 @@ export class ExpertClaimService {
|
|||||||
* - Unlocks the workflow so user can act
|
* - Unlocks the workflow so user can act
|
||||||
*/
|
*/
|
||||||
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
|
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
@@ -2179,12 +2207,15 @@ export class ExpertClaimService {
|
|||||||
/**
|
/**
|
||||||
* V2: Get claim list for damage expert
|
* V2: Get claim list for damage expert
|
||||||
*
|
*
|
||||||
* Returns claims that are:
|
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
|
||||||
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
|
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
|
||||||
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
|
* 2. Locked by this expert (in-progress)
|
||||||
* 3. Any status locked by THIS expert (their own in-progress work)
|
* 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
|
||||||
*/
|
*/
|
||||||
async getClaimListV2(actorId: string): Promise<GetClaimListV2ResponseDto> {
|
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
|
const clientKey = actor.clientKey as string;
|
||||||
const claims = await this.claimCaseDbService.find({
|
const claims = await this.claimCaseDbService.find({
|
||||||
$or: [
|
$or: [
|
||||||
// Available claims: waiting for expert, not locked
|
// Available claims: waiting for expert, not locked
|
||||||
@@ -2206,34 +2237,36 @@ export class ExpertClaimService {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = (claims as any[]).map((c) => {
|
const list = (claims as any[])
|
||||||
const awaitingFactorValidation =
|
.filter((c) => claimCaseTouchesClient(c, clientKey))
|
||||||
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
.map((c) => {
|
||||||
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
const awaitingFactorValidation =
|
||||||
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
return {
|
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||||
claimRequestId: c._id.toString(),
|
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
publicId: c.publicId,
|
return {
|
||||||
status: c.status,
|
claimRequestId: c._id.toString(),
|
||||||
currentStep: c.workflow?.currentStep || '',
|
publicId: c.publicId,
|
||||||
locked: c.workflow?.locked || false,
|
status: c.status,
|
||||||
lockedBy: c.workflow?.lockedBy
|
currentStep: c.workflow?.currentStep || '',
|
||||||
? {
|
locked: c.workflow?.locked || false,
|
||||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
lockedBy: c.workflow?.lockedBy
|
||||||
actorName: c.workflow.lockedBy.actorName,
|
? {
|
||||||
}
|
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||||
: undefined,
|
actorName: c.workflow.lockedBy.actorName,
|
||||||
vehicle: c.vehicle
|
}
|
||||||
? {
|
: undefined,
|
||||||
carName: c.vehicle.carName,
|
vehicle: c.vehicle
|
||||||
carModel: c.vehicle.carModel,
|
? {
|
||||||
carType: c.vehicle.carType,
|
carName: c.vehicle.carName,
|
||||||
}
|
carModel: c.vehicle.carModel,
|
||||||
: undefined,
|
carType: c.vehicle.carType,
|
||||||
createdAt: c.createdAt,
|
}
|
||||||
awaitingFactorValidation,
|
: undefined,
|
||||||
};
|
createdAt: c.createdAt,
|
||||||
}) as ClaimListItemV2Dto[];
|
awaitingFactorValidation,
|
||||||
|
};
|
||||||
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
return { list, total: list.length };
|
return { list, total: list.length };
|
||||||
}
|
}
|
||||||
@@ -2242,20 +2275,23 @@ export class ExpertClaimService {
|
|||||||
* V2: Get claim detail for damage expert
|
* V2: Get claim detail for damage expert
|
||||||
*
|
*
|
||||||
* Validations:
|
* Validations:
|
||||||
* - Claim must exist
|
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||||
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
|
* - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert)
|
||||||
* - If claim is locked, only the locking expert can access it
|
* - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION
|
||||||
*/
|
*/
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
|
const actorId = actor.sub;
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
const isPickupReview =
|
const isPickupReview =
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
const isFactorValidationPending =
|
const isFactorValidationPending =
|
||||||
@@ -2377,10 +2413,12 @@ export class ExpertClaimService {
|
|||||||
body: UpdateClaimDamagedPartsV2Dto,
|
body: UpdateClaimDamagedPartsV2Dto,
|
||||||
actor: any,
|
actor: any,
|
||||||
) {
|
) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class ExpertClaimV2Controller {
|
|||||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
||||||
})
|
})
|
||||||
async getClaimListV2(@CurrentUser() actor) {
|
async getClaimListV2(@CurrentUser() actor) {
|
||||||
return await this.expertClaimService.getClaimListV2(actor.sub);
|
return await this.expertClaimService.getClaimListV2(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("request/:claimRequestId")
|
@Get("request/:claimRequestId")
|
||||||
@@ -55,10 +55,7 @@ export class ExpertClaimV2Controller {
|
|||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertClaimService.getClaimDetailV2(
|
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
||||||
claimRequestId,
|
|
||||||
actor.sub,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put("lock/:claimRequestId")
|
@Put("lock/:claimRequestId")
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||||
@Get("/:expertId")
|
@Get("/:expertId")
|
||||||
async requestDetail(
|
async requestDetail(
|
||||||
|
@CurrentUser() insurer,
|
||||||
@Param("expertId") id: string,
|
@Param("expertId") id: string,
|
||||||
@Query("role") role: "claim" | "blame",
|
@Query("role") role: "claim" | "blame",
|
||||||
) {
|
) {
|
||||||
@@ -89,7 +90,11 @@ export class ExpertInsurerController {
|
|||||||
throw new BadRequestException("Invalid role");
|
throw new BadRequestException("Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
|
return await this.expertInsurerService.getAllFilesByExpertAndRole(
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
insurer.clientKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -153,6 +158,7 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||||
@Put("/:requestId/rating")
|
@Put("/:requestId/rating")
|
||||||
async rateExperts(
|
async rateExperts(
|
||||||
|
@CurrentUser() insurer,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@Body() rating: FileRating,
|
@Body() rating: FileRating,
|
||||||
@Query("role") role: "claim" | "blame",
|
@Query("role") role: "claim" | "blame",
|
||||||
@@ -165,13 +171,13 @@ export class ExpertInsurerController {
|
|||||||
requestId,
|
requestId,
|
||||||
rating,
|
rating,
|
||||||
role,
|
role,
|
||||||
|
insurer.clientKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
@ApiParam({ name: "insuranceId" })
|
@ApiParam({ name: "insuranceId" })
|
||||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
console.log("insuranceId", insuranceId);
|
|
||||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||||
insuranceId,
|
insuranceId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,163 +1,159 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { Types } from "mongoose";
|
||||||
import { Model, Types } from "mongoose";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
|
||||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
|
||||||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
|
||||||
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
|
||||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
|
||||||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
|
||||||
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 { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
|
|
||||||
import {
|
import {
|
||||||
FileRating,
|
blameCaseTouchesClient,
|
||||||
RequestManagementModel,
|
claimCaseTouchesClient,
|
||||||
} from "src/request-management/entities/schema/request-management.schema";
|
} from "src/helpers/tenant-scope";
|
||||||
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
|
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
||||||
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
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 { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertInsurerService {
|
export class ExpertInsurerService {
|
||||||
private readonly logger = new Logger(ExpertInsurerService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly client: ClientDbService,
|
|
||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
@InjectModel(ClaimRequestManagementModel.name)
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
@InjectModel(RequestManagementModel.name)
|
|
||||||
private readonly requestManagementModel: Model<RequestManagementModel>,
|
|
||||||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
|
||||||
private readonly blameVideoDbService: BlameVideoDbService,
|
|
||||||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
|
||||||
private readonly userSignDbService: UserSignDbService,
|
|
||||||
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
|
||||||
private readonly damageImageDbService: DamageImageDbService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async retrieveAllExpertsOfClient(
|
private getClientId(actorOrId: any): Types.ObjectId {
|
||||||
actor,
|
const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
||||||
currentPage: number,
|
if (!raw || !Types.ObjectId.isValid(raw)) {
|
||||||
countPerPage: number,
|
throw new BadRequestException("Client key is required");
|
||||||
) {
|
}
|
||||||
const { clientKey } = actor;
|
return new Types.ObjectId(raw);
|
||||||
if (!clientKey) return;
|
}
|
||||||
|
|
||||||
const clientObjectId = new Types.ObjectId(clientKey);
|
private parseObjectId(value: string, label: string): Types.ObjectId {
|
||||||
|
if (!Types.ObjectId.isValid(value)) {
|
||||||
|
throw new BadRequestException(`Invalid ${label}`);
|
||||||
|
}
|
||||||
|
return new Types.ObjectId(value);
|
||||||
|
}
|
||||||
|
|
||||||
const [experts, damageExperts] = await Promise.all([
|
private normalizeClaimCase(claim: any): any {
|
||||||
this.expertDbService.findAll({ clientKey: clientKey }),
|
return {
|
||||||
|
...claim,
|
||||||
|
requestNumber: claim.requestNo,
|
||||||
|
userClientKey: claim.owner?.userClientKey,
|
||||||
|
fullName: claim.owner?.fullName,
|
||||||
|
carDetail: claim.vehicle,
|
||||||
|
carPlate: claim.vehicle?.plate,
|
||||||
|
claimStatus: claim.status,
|
||||||
|
rating: claim.evaluation?.rating,
|
||||||
|
userRating: claim.evaluation?.userRating,
|
||||||
|
damageExpertReply: claim.evaluation?.damageExpertReply,
|
||||||
|
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
|
||||||
|
damageExpertResend: claim.evaluation?.damageExpertResend,
|
||||||
|
objection: claim.evaluation?.objection,
|
||||||
|
userResendDocuments: claim.evaluation?.userResendDocuments,
|
||||||
|
priceDrop: claim.evaluation?.priceDrop,
|
||||||
|
visitLocation: claim.evaluation?.visitLocation,
|
||||||
|
currentStep: claim.workflow?.currentStep,
|
||||||
|
nextStep: claim.workflow?.nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBlameCase(blame: any): any {
|
||||||
|
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
|
||||||
|
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
|
||||||
|
return {
|
||||||
|
...blame,
|
||||||
|
requestNumber: blame.requestNo,
|
||||||
|
rating: blame?.expert?.rating,
|
||||||
|
actorLocked: { actorId: blame?.expert?.assignedExpertId },
|
||||||
|
firstPartyDetails: {
|
||||||
|
firstPartyClient: { clientId: firstParty?.person?.clientId },
|
||||||
|
},
|
||||||
|
secondPartyDetails: {
|
||||||
|
secondPartyClient: { clientId: secondParty?.person?.clientId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCombinedFileScore(file: any): number | null {
|
||||||
|
const insurerRating = file?.rating;
|
||||||
|
const userRating = file?.userRating;
|
||||||
|
const insurerValues = insurerRating
|
||||||
|
? Object.values(insurerRating).filter(
|
||||||
|
(v): v is number => typeof v === "number" && !isNaN(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const userValues = userRating
|
||||||
|
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
||||||
|
(v) => typeof v === "number" && !isNaN(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const insurerAvg = insurerValues.length
|
||||||
|
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
||||||
|
: null;
|
||||||
|
const userAvg = userValues.length
|
||||||
|
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
||||||
|
: null;
|
||||||
|
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number");
|
||||||
|
if (!scores.length) return null;
|
||||||
|
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
||||||
|
const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||||
|
const idStr = String(clientObjectId);
|
||||||
|
return all
|
||||||
|
.filter((f) =>
|
||||||
|
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
f?.status !== CaseStatus.OPEN &&
|
||||||
|
f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||||
|
)
|
||||||
|
.map((f) => this.normalizeBlameCase(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
||||||
|
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
|
const idStr = String(clientObjectId);
|
||||||
|
return all
|
||||||
|
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
|
||||||
|
.map((f) => this.normalizeClaimCase(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
||||||
|
const clientObjectId = this.getClientId(actor);
|
||||||
|
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||||||
|
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
|
||||||
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
||||||
|
this.getClientBlameFiles(clientObjectId),
|
||||||
|
this.getClientClaimFiles(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allExpertsRaw = [...experts, ...damageExperts];
|
const allExpertsRaw = [...experts, ...damageExperts];
|
||||||
const expertIds = allExpertsRaw.map((expert) => expert._id.toString());
|
|
||||||
|
|
||||||
if (expertIds.length === 0)
|
|
||||||
return { total: 0, page: currentPage, countPerPage, experts: [] };
|
|
||||||
|
|
||||||
const expertObjectIds = expertIds.map((id) => new Types.ObjectId(id));
|
|
||||||
|
|
||||||
const blameFileQuery = {
|
|
||||||
$and: [
|
|
||||||
{
|
|
||||||
$or: [
|
|
||||||
{ "firstPartyDetails.firstPartyClient.clientId": clientObjectId },
|
|
||||||
{ "secondPartyDetails.secondPartyClient.clientId": clientObjectId },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ "actorLocked.actorId": { $in: expertObjectIds } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const claimFileQuery = {
|
|
||||||
$and: [
|
|
||||||
{ userClientKey: clientObjectId },
|
|
||||||
{ "damageExpertReply.actorDetail.actorId": { $in: expertIds } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const [blameFiles, claimFiles] = await Promise.all([
|
|
||||||
this.requestManagementModel.find(blameFileQuery).lean(),
|
|
||||||
this.claimRequestManagementModel.find(claimFileQuery).lean(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const expertTotalRatingsMap: Record<string, number[]> = {};
|
const expertTotalRatingsMap: Record<string, number[]> = {};
|
||||||
const expertRatingsByCategoryMap: Record<
|
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
||||||
string,
|
|
||||||
Record<string, number[]>
|
|
||||||
> = {};
|
|
||||||
|
|
||||||
/**
|
const processRatings = (expertId: string | undefined, file: any) => {
|
||||||
* Converts insurer FileRating and optional userRating into a single
|
|
||||||
* combined score for a file.
|
|
||||||
*/
|
|
||||||
const getCombinedFileScore = (file: any): number | null => {
|
|
||||||
const insurerRating = file?.rating;
|
|
||||||
const userRating = file?.userRating;
|
|
||||||
|
|
||||||
const insurerValues = insurerRating
|
|
||||||
? Object.values(insurerRating).filter(
|
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const insurerAvg =
|
|
||||||
insurerValues.length > 0
|
|
||||||
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const userValues = userRating
|
|
||||||
? [
|
|
||||||
userRating.progressSpeed,
|
|
||||||
userRating.registrationEase,
|
|
||||||
userRating.overallEvaluation,
|
|
||||||
].filter((val) => typeof val === "number" && !isNaN(val))
|
|
||||||
: [];
|
|
||||||
const userAvg =
|
|
||||||
userValues.length > 0
|
|
||||||
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const scores = [insurerAvg, userAvg].filter(
|
|
||||||
(v): v is number => typeof v === "number" && !isNaN(v),
|
|
||||||
);
|
|
||||||
if (scores.length === 0) return null;
|
|
||||||
return parseFloat(
|
|
||||||
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processRatings = (expertId: string, file: any) => {
|
|
||||||
if (!expertId) return;
|
if (!expertId) return;
|
||||||
|
|
||||||
const rating = file?.rating;
|
const rating = file?.rating;
|
||||||
const combinedScore = getCombinedFileScore(file);
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
|
|
||||||
// Aggregate overall combined score for this expert
|
|
||||||
if (combinedScore !== null) {
|
if (combinedScore !== null) {
|
||||||
if (!expertTotalRatingsMap[expertId])
|
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
|
||||||
expertTotalRatingsMap[expertId] = [];
|
|
||||||
expertTotalRatingsMap[expertId].push(combinedScore);
|
expertTotalRatingsMap[expertId].push(combinedScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep backward-compatible per-category insurer ratings
|
|
||||||
if (!rating || typeof rating !== "object") return;
|
if (!rating || typeof rating !== "object") return;
|
||||||
|
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
||||||
if (!expertRatingsByCategoryMap[expertId]) {
|
|
||||||
expertRatingsByCategoryMap[expertId] = {};
|
|
||||||
}
|
|
||||||
for (const [category, value] of Object.entries(rating)) {
|
for (const [category, value] of Object.entries(rating)) {
|
||||||
if (typeof value === "number" && !isNaN(value)) {
|
if (typeof value === "number" && !isNaN(value)) {
|
||||||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
if (!expertRatingsByCategoryMap[expertId][category]) {
|
||||||
@@ -169,37 +165,29 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const file of blameFiles) {
|
for (const file of blameFiles) {
|
||||||
const expertId = file?.actorLocked?.actorId?.toString();
|
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
|
||||||
processRatings(expertId, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of claimFiles) {
|
for (const file of claimFiles) {
|
||||||
const expertId = file?.damageExpertReply?.actorDetail?.actorId;
|
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
||||||
processRatings(expertId, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allExperts = allExpertsRaw.map((expert) => {
|
const allExperts = allExpertsRaw.map((expert) => {
|
||||||
const expertIdStr = expert._id.toString();
|
const expertIdStr = expert._id.toString();
|
||||||
|
|
||||||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
||||||
const overallAverageRating = totalRatings.length
|
const overallAverageRating = totalRatings.length
|
||||||
? parseFloat(
|
? parseFloat(
|
||||||
(
|
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
|
||||||
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
|
|
||||||
).toFixed(2),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const averageRatingsByCategory: Record<string, number> = {};
|
const averageRatingsByCategory: Record<string, number> = {};
|
||||||
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
||||||
if (ratingsByCat) {
|
if (ratingsByCat) {
|
||||||
for (const [category, values] of Object.entries(ratingsByCat)) {
|
for (const [category, values] of Object.entries(ratingsByCat)) {
|
||||||
const sum = values.reduce((a, b) => a + b, 0);
|
averageRatingsByCategory[category] = parseFloat(
|
||||||
const average = parseFloat((sum / values.length).toFixed(2));
|
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
|
||||||
averageRatingsByCategory[category] = average;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: expert._id,
|
_id: expert._id,
|
||||||
fullName: `${expert.firstName} ${expert.lastName}`,
|
fullName: `${expert.firstName} ${expert.lastName}`,
|
||||||
@@ -212,14 +200,14 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const start = (currentPage - 1) * countPerPage;
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||||
const paginated = allExperts.slice(start, start + countPerPage);
|
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
||||||
|
const start = (page - 1) * perPage;
|
||||||
return {
|
return {
|
||||||
total: allExperts.length,
|
total: allExperts.length,
|
||||||
page: currentPage,
|
page,
|
||||||
countPerPage,
|
countPerPage: perPage,
|
||||||
experts: paginated,
|
experts: allExperts.slice(start, start + perPage),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,510 +233,125 @@ export class ExpertInsurerService {
|
|||||||
* combined insurer + user ratings.
|
* combined insurer + user ratings.
|
||||||
*/
|
*/
|
||||||
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
||||||
const id = new Types.ObjectId(insurerId);
|
const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||||||
|
|
||||||
const claimFiles = await this.claimRequestManagementModel
|
|
||||||
.find(
|
|
||||||
{
|
|
||||||
userClientKey: id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
carPlate: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
rating: 1,
|
|
||||||
userRating: 1,
|
|
||||||
damageExpertReply: 1,
|
|
||||||
damageExpertReplyFinal: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const scored = claimFiles
|
const scored = claimFiles
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const insurerRating = file?.rating;
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
const userRating = file?.userRating;
|
if (combinedScore === null) return null;
|
||||||
|
return { ...file, combinedScore };
|
||||||
const insurerValues = insurerRating
|
|
||||||
? Object.values(insurerRating).filter(
|
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const insurerAvg =
|
|
||||||
insurerValues.length > 0
|
|
||||||
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const userValues = userRating
|
|
||||||
? [
|
|
||||||
userRating.progressSpeed,
|
|
||||||
userRating.registrationEase,
|
|
||||||
userRating.overallEvaluation,
|
|
||||||
].filter((val) => typeof val === "number" && !isNaN(val))
|
|
||||||
: [];
|
|
||||||
const userAvg =
|
|
||||||
userValues.length > 0
|
|
||||||
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const scores = [insurerAvg, userAvg].filter(
|
|
||||||
(v): v is number => typeof v === "number" && !isNaN(v),
|
|
||||||
);
|
|
||||||
if (scores.length === 0) return null;
|
|
||||||
|
|
||||||
const combinedScore =
|
|
||||||
scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
combinedScore: parseFloat(combinedScore.toFixed(2)),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.filter((f) => f !== null);
|
.filter((f) => f !== null);
|
||||||
|
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
|
||||||
const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore);
|
|
||||||
return sorted.slice(0, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") {
|
private async assertExpertBelongsToInsurer(
|
||||||
const expertObjectId = new Types.ObjectId(expertId);
|
expertObjectId: Types.ObjectId,
|
||||||
|
insurerClientKey: string,
|
||||||
const model =
|
) {
|
||||||
role === "claim"
|
const ck = String(insurerClientKey);
|
||||||
? this.claimRequestManagementModel
|
const clientOid = new Types.ObjectId(ck);
|
||||||
: this.requestManagementModel;
|
const [experts, damageExperts] = await Promise.all([
|
||||||
const expertCollection = role === "claim" ? "damage-expert" : "expert";
|
this.expertDbService.findAll({ clientKey: ck }),
|
||||||
|
this.damageExpertDbService.findAll({ clientKey: clientOid }),
|
||||||
const results = await model.aggregate([
|
|
||||||
{ $match: { "actorLocked.actorId": expertObjectId } },
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: expertCollection,
|
|
||||||
localField: "actorLocked.actorId",
|
|
||||||
foreignField: "_id",
|
|
||||||
as: "expertInfo",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $unwind: { path: "$expertInfo", preserveNullAndEmptyArrays: true } },
|
|
||||||
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
averageRating: {
|
|
||||||
$cond: [
|
|
||||||
{ $ne: ["$rating", null] },
|
|
||||||
{
|
|
||||||
$avg: [
|
|
||||||
"$rating.collisionMethodAccuracy",
|
|
||||||
"$rating.evaluationTimeliness",
|
|
||||||
"$rating.accidentCauseAccuracy",
|
|
||||||
"$rating.guiltyVehicleIdentification",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
$project: {
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
steps: 1,
|
|
||||||
currentStep: 1,
|
|
||||||
rating: 1,
|
|
||||||
averageRating: 1,
|
|
||||||
imageRequired: 1,
|
|
||||||
expertInfo: {
|
|
||||||
_id: 1,
|
|
||||||
fullName: {
|
|
||||||
$concat: ["$expertInfo.firstName", " ", "$expertInfo.lastName"],
|
|
||||||
},
|
|
||||||
requestStats: 1,
|
|
||||||
userType: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
const id = String(expertObjectId);
|
||||||
// Process imageRequired for claim files
|
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
|
||||||
if (role === "claim") {
|
if (!ok) {
|
||||||
return await Promise.all(
|
throw new ForbiddenException(
|
||||||
results.map((file) => this.processImageRequired(file)),
|
"This expert is not registered under your insurance company.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
async getAllFilesByExpertAndRole(
|
||||||
|
expertId: string,
|
||||||
|
role: "claim" | "blame",
|
||||||
|
insurerClientKey: string,
|
||||||
|
) {
|
||||||
|
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||||||
|
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
|
||||||
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
||||||
|
|
||||||
|
if (role === "claim") {
|
||||||
|
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
|
return claims
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
claimCaseTouchesClient(c, clientKeyStr) &&
|
||||||
|
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
|
||||||
|
String(expertObjectId),
|
||||||
|
)
|
||||||
|
.map((c) => this.normalizeClaimCase(c));
|
||||||
|
}
|
||||||
|
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||||
|
return blames
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
blameCaseTouchesClient(b, clientKeyStr) &&
|
||||||
|
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
|
||||||
|
)
|
||||||
|
.map((b) => this.normalizeBlameCase(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
async rateExpertOnFile(
|
async rateExpertOnFile(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
rating: FileRating,
|
rating: FileRating,
|
||||||
role: "claim" | "blame",
|
role: "claim" | "blame",
|
||||||
|
insurerClientKey: string,
|
||||||
) {
|
) {
|
||||||
const _id = new Types.ObjectId(requestId);
|
const _id = this.parseObjectId(requestId, "request id");
|
||||||
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
||||||
// Validate each rating factor
|
for (const [key, value] of Object.entries(rating || {})) {
|
||||||
const fields = Object.entries(rating);
|
|
||||||
for (const [key, value] of fields) {
|
|
||||||
if (typeof value !== "number" || value < 0 || value > 5) {
|
if (typeof value !== "number" || value < 0 || value > 5) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`${key} must be a number between 0 and 5`);
|
||||||
`${key} must be a number between 0 and 5`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "claim") {
|
if (role === "claim") {
|
||||||
const updated = await this.claimRequestManagementModel.findByIdAndUpdate(
|
const existing = await this.claimCaseDbService.findById(_id);
|
||||||
_id,
|
if (!existing) throw new NotFoundException("Claim file not found");
|
||||||
{ $set: { rating } },
|
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||||
{ new: true },
|
throw new ForbiddenException("This claim does not belong to your organization.");
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException("Claim file not found");
|
|
||||||
}
|
}
|
||||||
|
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
|
||||||
|
$set: { "evaluation.rating": rating },
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Claim file not found");
|
||||||
return {
|
return {
|
||||||
message: "Claim expert rated successfully",
|
message: "Claim expert rated successfully",
|
||||||
updatedRating: updated.rating,
|
updatedRating: (updated as any)?.evaluation?.rating || rating,
|
||||||
requestId: updated._id,
|
requestId: updated._id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "blame") {
|
if (role === "blame") {
|
||||||
const updated = await this.requestManagementModel.findByIdAndUpdate(
|
const existing = await this.blameRequestDbService.findById(_id);
|
||||||
_id,
|
if (!existing) throw new NotFoundException("Blame file not found");
|
||||||
{ $set: { rating } },
|
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||||
{ new: true },
|
throw new ForbiddenException("This blame case does not belong to your organization.");
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException("Blame file not found");
|
|
||||||
}
|
}
|
||||||
|
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
|
||||||
|
$set: { "expert.rating": rating },
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Blame file not found");
|
||||||
return {
|
return {
|
||||||
message: "Blame expert rated successfully",
|
message: "Blame expert rated successfully",
|
||||||
updatedRating: updated.rating,
|
updatedRating: (updated as any)?.expert?.rating || rating,
|
||||||
requestId: updated._id,
|
requestId: updated._id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException("Invalid role");
|
throw new BadRequestException("Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveAllFilesOfClient(insurerId: string) {
|
async retrieveAllFilesOfClient(insurerId: string) {
|
||||||
const id = new Types.ObjectId(insurerId);
|
const id = this.getClientId(insurerId);
|
||||||
|
const [blameFiles, claimFiles] = await Promise.all([
|
||||||
const blameFilesRaw = await this.requestManagementModel
|
this.getClientBlameFiles(id),
|
||||||
.find({
|
this.getClientClaimFiles(id),
|
||||||
"firstPartyDetails.firstPartyClient.clientId": id,
|
]);
|
||||||
blameStatus: {
|
return { blameFiles, claimFiles };
|
||||||
$nin: ["PendingForFirstParty", "PendingForSecondParty"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const claimFilesRaw = await this.claimRequestManagementModel
|
|
||||||
.find({
|
|
||||||
userClientKey: id,
|
|
||||||
}, {
|
|
||||||
_id: 1,
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
carPlate: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
currentStep: 1,
|
|
||||||
nextStep: 1,
|
|
||||||
carPartDamage: 1,
|
|
||||||
otherParts: 1,
|
|
||||||
sheba: 1,
|
|
||||||
nationalCodeOfInsurer: 1,
|
|
||||||
carGreenCard: 1,
|
|
||||||
aiImages: 1,
|
|
||||||
imageRequired: 1,
|
|
||||||
videoCaptureId: 1,
|
|
||||||
damageExpertReply: 1,
|
|
||||||
damageExpertReplyFinal: 1,
|
|
||||||
damageExpertResend: 1,
|
|
||||||
objection: 1,
|
|
||||||
userResendDocuments: 1,
|
|
||||||
priceDrop: 1,
|
|
||||||
requiredDocuments: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1,
|
|
||||||
rating: 1,
|
|
||||||
visitLocation: 1,
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const populatedBlameFiles = await Promise.all(
|
|
||||||
blameFilesRaw.map((file) => this.populateBlameFileLinks(file)),
|
|
||||||
);
|
|
||||||
const populatedClaimFiles = await Promise.all(
|
|
||||||
claimFilesRaw.map(async (file) => {
|
|
||||||
const populated = await this.populateClaimFileLinks(file);
|
|
||||||
return await this.processImageRequired(populated);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { blameFiles: populatedBlameFiles, claimFiles: populatedClaimFiles };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateBlameFileLinks(blameFile: any): Promise<any> {
|
|
||||||
if (!blameFile) return blameFile;
|
|
||||||
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (blameFile.firstPartyDetails?.firstPartyFile?.firstPartyVideoId) {
|
|
||||||
const videoDoc = await this.blameVideoDbService.findById(
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId.toString(),
|
|
||||||
);
|
|
||||||
if (videoDoc)
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId =
|
|
||||||
buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
if (Array.isArray(blameFile.firstPartyDetails?.firstPartyFile?.voices)) {
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.voices =
|
|
||||||
await this.populateIdArray(
|
|
||||||
this.blameVoiceDbService,
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.voices,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (Array.isArray(blameFile.secondPartyDetails?.secondPartyFiles?.voices)) {
|
|
||||||
blameFile.secondPartyDetails.secondPartyFiles.voices =
|
|
||||||
await this.populateIdArray(
|
|
||||||
this.blameVoiceDbService,
|
|
||||||
blameFile.secondPartyDetails.secondPartyFiles.voices,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blameFile.expertResendReply) {
|
|
||||||
await this.populatePartyReplyLinks(
|
|
||||||
blameFile.expertResendReply.firstParty,
|
|
||||||
);
|
|
||||||
await this.populatePartyReplyLinks(
|
|
||||||
blameFile.expertResendReply.secondParty,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalReply =
|
|
||||||
blameFile.expertSubmitReplyFinal || blameFile.expertSubmitReply;
|
|
||||||
if (finalReply) {
|
|
||||||
await this.populateSignatureLink(finalReply.firstPartyComment);
|
|
||||||
await this.populateSignatureLink(finalReply.secondPartyComment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blameFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateClaimFileLinks(claimFile: any): Promise<any> {
|
|
||||||
if (!claimFile) return claimFile;
|
|
||||||
|
|
||||||
if (claimFile.blameFile) {
|
|
||||||
claimFile.blameFile = await this.populateBlameFileLinks(
|
|
||||||
claimFile.blameFile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (claimFile.videoCaptureId) {
|
|
||||||
const videoDoc = await this.claimVideoCaptureDbService.findById(
|
|
||||||
claimFile.videoCaptureId._id.toString(),
|
|
||||||
);
|
|
||||||
if (videoDoc) claimFile.videoCaptureId = buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claimFile.requiredDocuments) {
|
|
||||||
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
|
|
||||||
claimFile._id.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Populate with file URLs
|
|
||||||
const populatedDocuments = documents.map((doc) => ({
|
|
||||||
_id: doc._id,
|
|
||||||
documentType: doc.documentType,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
fileUrl: buildFileLink(doc.path),
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
claimFile.requiredDocuments = populatedDocuments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes imageRequired field:
|
|
||||||
* 1. Removes part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
* 2. Populates imageId fields with file links
|
|
||||||
*/
|
|
||||||
private async processImageRequired(claimFile: any): Promise<any> {
|
|
||||||
if (!claimFile || !claimFile.imageRequired) {
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageRequired = claimFile.imageRequired;
|
|
||||||
|
|
||||||
// Process aroundTheCar array
|
|
||||||
if (Array.isArray(imageRequired.aroundTheCar)) {
|
|
||||||
imageRequired.aroundTheCar = await Promise.all(
|
|
||||||
imageRequired.aroundTheCar.map(async (item: any) => {
|
|
||||||
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
if (
|
|
||||||
item?.aiReport?.distinct_damaged_parts_report?.parts &&
|
|
||||||
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
|
|
||||||
) {
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts =
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts.map(
|
|
||||||
(part: any) => {
|
|
||||||
const { part_segments, ...partWithoutSegments } = part;
|
|
||||||
return partWithoutSegments;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate imageId with file link
|
|
||||||
if (item?.imageId) {
|
|
||||||
try {
|
|
||||||
const imageDoc = await this.damageImageDbService.findOne(
|
|
||||||
item.imageId.toString(),
|
|
||||||
);
|
|
||||||
if (imageDoc && imageDoc.path) {
|
|
||||||
item.imageId = buildFileLink(imageDoc.path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to populate imageId for aroundTheCar item: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process selectPartOfCar array
|
|
||||||
if (Array.isArray(imageRequired.selectPartOfCar)) {
|
|
||||||
imageRequired.selectPartOfCar = await Promise.all(
|
|
||||||
imageRequired.selectPartOfCar.map(async (item: any) => {
|
|
||||||
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
if (
|
|
||||||
item?.aiReport?.distinct_damaged_parts_report?.parts &&
|
|
||||||
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
|
|
||||||
) {
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts =
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts.map(
|
|
||||||
(part: any) => {
|
|
||||||
const { part_segments, ...partWithoutSegments } = part;
|
|
||||||
return partWithoutSegments;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate imageId with file link
|
|
||||||
if (item?.imageId) {
|
|
||||||
try {
|
|
||||||
const imageDoc = await this.damageImageDbService.findOne(
|
|
||||||
item.imageId.toString(),
|
|
||||||
);
|
|
||||||
if (imageDoc && imageDoc.path) {
|
|
||||||
item.imageId = buildFileLink(imageDoc.path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to populate imageId for selectPartOfCar item: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populatePartyReplyLinks(partyReply: any) {
|
|
||||||
if (!partyReply) return;
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (partyReply.voice) {
|
|
||||||
const voiceDoc = await this.blameVoiceDbService.findById(
|
|
||||||
partyReply.voice.toString(),
|
|
||||||
);
|
|
||||||
if (voiceDoc) partyReply.voice = buildFileLink(voiceDoc.path);
|
|
||||||
}
|
|
||||||
if (partyReply.documents) {
|
|
||||||
for (const docType in partyReply.documents) {
|
|
||||||
const docId = partyReply.documents[docType];
|
|
||||||
if (docId) {
|
|
||||||
const doc = await this.blameDocumentDbService.findById(
|
|
||||||
docId.toString(),
|
|
||||||
);
|
|
||||||
if (doc) partyReply.documents[docType] = buildFileLink(doc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateSignatureLink(comment: any) {
|
|
||||||
if (comment?.signDetail?.fileId) {
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
const signDoc = await this.userSignDbService.findById(
|
|
||||||
comment.signDetail.fileId.toString(),
|
|
||||||
);
|
|
||||||
if (signDoc)
|
|
||||||
(comment.signDetail as any).fileUrl = buildFileLink(signDoc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateIdArray(dbService: any, ids: any[]): Promise<string[]> {
|
|
||||||
return Promise.all(
|
|
||||||
ids.map(async (id) => {
|
|
||||||
if (!id) return id;
|
|
||||||
|
|
||||||
if (typeof id === "object" && id.path) {
|
|
||||||
return buildFileLink(id.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idString = id.toString();
|
|
||||||
if (idString.includes("[object Object]")) {
|
|
||||||
this.logger.warn(
|
|
||||||
"Invalid object found in ID array, skipping population.",
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
const doc = await dbService.findById(idString);
|
|
||||||
return doc ? buildFileLink(doc.path) : id;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to populate ID: ${idString}`, error);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
||||||
const clientId = new Types.ObjectId(clientKey);
|
const clientId = this.getClientId(clientKey);
|
||||||
|
|
||||||
const existingBranch = await this.branchDbService.findOne({
|
const existingBranch = await this.branchDbService.findOne({
|
||||||
clientKey: clientId,
|
clientKey: clientId,
|
||||||
@@ -778,19 +381,8 @@ export class ExpertInsurerService {
|
|||||||
* - Number of files created in the current month
|
* - Number of files created in the current month
|
||||||
*/
|
*/
|
||||||
async getExpertStatisticsReport(actor: any) {
|
async getExpertStatisticsReport(actor: any) {
|
||||||
const { clientKey } = actor;
|
const clientObjectId = this.getClientId(actor);
|
||||||
if (!clientKey) {
|
const claimFiles = await this.getClientClaimFiles(clientObjectId);
|
||||||
throw new BadRequestException("Client key is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientObjectId = new Types.ObjectId(clientKey);
|
|
||||||
|
|
||||||
// Get all claim files for this client
|
|
||||||
const claimFiles = await this.claimRequestManagementModel
|
|
||||||
.find({
|
|
||||||
userClientKey: clientObjectId,
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
// Calculate current month date range
|
// Calculate current month date range
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -907,6 +499,6 @@ export class ExpertInsurerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async retrieveInsuranceBranches(insuranceId: string) {
|
async retrieveInsuranceBranches(insuranceId: string) {
|
||||||
return await this.branchDbService.findAll(insuranceId);
|
return this.branchDbService.findAll(insuranceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/helpers/tenant-scope.ts
Normal file
66
src/helpers/tenant-scope.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
|
||||||
|
export function requireActorClientKey(actor: { clientKey?: string }): string {
|
||||||
|
const ck = actor?.clientKey;
|
||||||
|
if (!ck || typeof ck !== "string") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ck;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
|
||||||
|
const id = String(clientKey);
|
||||||
|
return (doc?.parties ?? []).some(
|
||||||
|
(p: any) => String(p?.person?.clientId ?? "") === id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
|
||||||
|
return String(doc?.owner?.userClientKey ?? "") === String(clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field expert–initiated cases may not yet have party clientId filled; the
|
||||||
|
* initiating expert is always scoped to their insurer via JWT.
|
||||||
|
*/
|
||||||
|
export function blameCaseAccessibleToExpert(
|
||||||
|
doc: any,
|
||||||
|
actor: { sub: string; clientKey?: string },
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
doc.expertInitiated &&
|
||||||
|
doc.initiatedByFieldExpertId &&
|
||||||
|
String(doc.initiatedByFieldExpertId) === actor.sub
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ck = actor?.clientKey;
|
||||||
|
if (!ck) return false;
|
||||||
|
return blameCaseTouchesClient(doc, ck);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBlameCaseForExpertTenant(
|
||||||
|
doc: any,
|
||||||
|
actor: { sub: string; clientKey?: string },
|
||||||
|
): void {
|
||||||
|
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This blame case does not belong to your organization.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertClaimCaseForTenant(
|
||||||
|
doc: any,
|
||||||
|
actor: { clientKey?: string },
|
||||||
|
): void {
|
||||||
|
const ck = requireActorClientKey(actor);
|
||||||
|
if (!claimCaseTouchesClient(doc, ck)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This claim does not belong to your organization.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,28 @@
|
|||||||
export class DamageExpertAllRequestsCountReportDtoRs {
|
/**
|
||||||
all: number;
|
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
|
||||||
UnChecked: number;
|
* plus `all` = total matching documents for the tenant.
|
||||||
CheckedRequest: number;
|
*/
|
||||||
CheckAgain: number;
|
export class BlameCaseStatusCountReportDtoRs {
|
||||||
WaitingForUserToResend: number;
|
[key: string]: number;
|
||||||
CloseRequest: number;
|
|
||||||
WaitingForUserCompleted: number;
|
|
||||||
InPersonVisit: number;
|
|
||||||
|
|
||||||
constructor(report: any) {
|
constructor(report: Record<string, number>) {
|
||||||
this.all = report.all;
|
Object.assign(this, report);
|
||||||
this.UnChecked = report.UnChecked;
|
|
||||||
this.CheckedRequest = report.CheckedRequest;
|
|
||||||
this.CheckAgain = report.CheckAgain;
|
|
||||||
this.WaitingForUserToResend = report.WaitingForUserToResend;
|
|
||||||
this.CloseRequest = report.CloseRequest;
|
|
||||||
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
|
|
||||||
this.InPersonVisit = report.InPersonVisit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExpertAllRequestsCountReportDtoRs {
|
export class ClaimCaseStatusCountReportDtoRs {
|
||||||
all: number;
|
[key: string]: number;
|
||||||
UnChecked: number;
|
|
||||||
CheckedRequest: number;
|
|
||||||
CheckAgain: number;
|
|
||||||
WaitingForUserToResend: number;
|
|
||||||
CloseRequest: number;
|
|
||||||
WaitingForUserCompleted: number;
|
|
||||||
|
|
||||||
constructor(report: any) {
|
constructor(report: Record<string, number>) {
|
||||||
this.all = report.all;
|
Object.assign(this, report);
|
||||||
this.UnChecked = report.UnChecked;
|
|
||||||
this.CheckedRequest = report.CheckedRequest;
|
|
||||||
this.CheckAgain = report.CheckAgain;
|
|
||||||
this.WaitingForUserToResend = report.WaitingForUserToResend;
|
|
||||||
this.CloseRequest = report.CloseRequest;
|
|
||||||
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CompanyAllRequestsCountReportDtoRs {
|
export class CompanyAllRequestsCountReportDtoRs {
|
||||||
blame: object;
|
blame: Record<string, number>;
|
||||||
claim: object;
|
claim: Record<string, number>;
|
||||||
|
|
||||||
constructor(blame: object, claim: object) {
|
constructor(blame: Record<string, number>, claim: Record<string, number>) {
|
||||||
this.blame = blame;
|
this.blame = blame;
|
||||||
this.claim = claim;
|
this.claim = claim;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||||
import { ClientModule } from "src/client/client.module";
|
|
||||||
import { ReportsController } from "./reports.controller";
|
import { ReportsController } from "./reports.controller";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service";
|
|||||||
imports: [
|
imports: [
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
ClientModule,
|
|
||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
|||||||
@@ -1,413 +1,243 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
import { requireActorClientKey } from "src/helpers/tenant-scope";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
|
||||||
import {
|
import {
|
||||||
|
BlameCaseStatusCountReportDtoRs,
|
||||||
|
ClaimCaseStatusCountReportDtoRs,
|
||||||
CompanyAllRequestsCountReportDtoRs,
|
CompanyAllRequestsCountReportDtoRs,
|
||||||
DamageExpertAllRequestsCountReportDtoRs,
|
|
||||||
ExpertAllRequestsCountReportDtoRs,
|
|
||||||
} from "./dto/reports.dto";
|
} from "./dto/reports.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
private readonly logger = new Logger(ReportsService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementDbService: RequestManagementDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
private readonly clientDbService: ClientDbService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAllCheckedRequestsCountFn(role: string, client: string) {
|
private clientObjectId(client: string | undefined): Types.ObjectId {
|
||||||
const statuses = ["UnChecked", "CheckedRequest"];
|
const ck = requireActorClientKey({ clientKey: client });
|
||||||
const data: Record<string, number> = { all: 0 };
|
return new Types.ObjectId(ck);
|
||||||
const clientId = new Types.ObjectId(client);
|
|
||||||
|
|
||||||
for (const status of statuses) {
|
|
||||||
let count = 0;
|
|
||||||
if (role === "damage_expert") {
|
|
||||||
count = await this.claimRequestManagementDbService.countByFilter({
|
|
||||||
claimStatus: status,
|
|
||||||
userClientKey: clientId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
count = await this.requestManagementDbService.countByFilter({
|
|
||||||
blameStatus: status,
|
|
||||||
$or: [
|
|
||||||
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
|
|
||||||
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
data[status] = count;
|
|
||||||
data.all += count;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDateFilteredRequestByStatus(
|
private isBlameForClient(r: any, client: Types.ObjectId): boolean {
|
||||||
role: string,
|
const idStr = String(client);
|
||||||
client: string,
|
return (r?.parties || []).some(
|
||||||
start: Date,
|
(p) => String(p?.person?.clientId || "") === idStr,
|
||||||
end: Date,
|
|
||||||
) {
|
|
||||||
const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"];
|
|
||||||
const data: Record<string, number> = {};
|
|
||||||
const clientId = new Types.ObjectId(client);
|
|
||||||
|
|
||||||
for (const status of statuses) {
|
|
||||||
let blameCount = 0;
|
|
||||||
let claimCount = 0;
|
|
||||||
|
|
||||||
if (role === "expert" || role === "company") {
|
|
||||||
blameCount = await this.requestManagementDbService.countByFilter({
|
|
||||||
blameStatus: status,
|
|
||||||
createdAt: { $gte: start, $lte: end },
|
|
||||||
$or: [
|
|
||||||
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
|
|
||||||
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "damage_expert" || role === "company") {
|
|
||||||
claimCount = await this.claimRequestManagementDbService.countByFilter({
|
|
||||||
claimStatus: status,
|
|
||||||
userClientKey: clientId,
|
|
||||||
createdAt: { $gte: start, $lte: end },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
data[status] = blameCount + claimCount;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isVisibleToClientType(client: any, actor: any): boolean {
|
|
||||||
if (actor.userType === UserType.GENUINE) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
actor.userType === UserType.LEGAL &&
|
|
||||||
String(client._id) === actor.clientKey
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private wasHandledByActor(request: any, actorSub: string): boolean {
|
|
||||||
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
|
|
||||||
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
|
|
||||||
|
|
||||||
if (!Array.isArray(actorChecker)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingEntry = actorChecker.find(
|
|
||||||
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return !!matchingEntry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private isClaimForClient(r: any, client: Types.ObjectId): boolean {
|
||||||
* Filters claim requests using the same logic as expert-claim service
|
return String(r?.owner?.userClientKey || "") === String(client);
|
||||||
* to ensure consistency between endpoints
|
}
|
||||||
*/
|
|
||||||
private async filterClaimRequestsForExpert(
|
|
||||||
requests: any[],
|
|
||||||
actor: any,
|
|
||||||
): Promise<any[]> {
|
|
||||||
const filteredRequests = [];
|
|
||||||
|
|
||||||
for (const r of requests) {
|
private countBlameByCaseStatus(
|
||||||
// For expert-initiated blame files, only show to the initiating expert
|
blames: any[],
|
||||||
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
|
clientId: Types.ObjectId,
|
||||||
if (String(r.blameFile.initiatedBy) !== actor.sub) {
|
): Record<string, number> {
|
||||||
continue; // Skip if not the initiating expert
|
const out: Record<string, number> = { all: 0 };
|
||||||
}
|
for (const s of Object.values(CaseStatus)) {
|
||||||
// Expert-initiated claim files are always visible to the initiating expert
|
out[s] = 0;
|
||||||
filteredRequests.push(r);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await this.clientDbService.findOne({
|
|
||||||
_id: r.userClientKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Client not found for claim request with ID: ${r._id}. Skipping.`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const specialHandlingStatuses = [
|
|
||||||
ReqClaimStatus.CheckAgain,
|
|
||||||
ReqClaimStatus.ReviewRequest,
|
|
||||||
ReqClaimStatus.PendingFactorValidation,
|
|
||||||
];
|
|
||||||
|
|
||||||
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
|
|
||||||
r.claimStatus,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requiresSpecificActorCheck) {
|
|
||||||
if (this.wasHandledByActor(r, actor.sub)) {
|
|
||||||
filteredRequests.push(r);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.isVisibleToClientType(client, actor)) {
|
|
||||||
filteredRequests.push(r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (const r of blames) {
|
||||||
return filteredRequests;
|
if (!this.isBlameForClient(r, clientId)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsCountByRole(role: string, client: string, actor?: any) {
|
private countClaimByCaseStatus(
|
||||||
|
claims: any[],
|
||||||
|
clientId: Types.ObjectId,
|
||||||
|
): Record<string, number> {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) {
|
||||||
|
out[s] = 0;
|
||||||
|
}
|
||||||
|
for (const r of claims) {
|
||||||
|
if (!this.isClaimForClient(r, clientId)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllRequestsCountByRole(role: string, client: string | undefined) {
|
||||||
|
const clientId = this.clientObjectId(client);
|
||||||
|
const [blames, claims] = await Promise.all([
|
||||||
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (role === "expert") {
|
if (role === "expert") {
|
||||||
const statuses = Object.values(ReqBlameStatus);
|
return this.countBlameByCaseStatus(blames as any[], clientId);
|
||||||
const data: Record<string, number> = { all: 0 };
|
|
||||||
|
|
||||||
for (const status of statuses) {
|
|
||||||
const filter = {
|
|
||||||
blameStatus: status,
|
|
||||||
$or: [
|
|
||||||
{
|
|
||||||
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
|
|
||||||
client,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// "secondPartyDetails.secondPartyClient.clientId":
|
|
||||||
// new Types.ObjectId(client),
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const count =
|
|
||||||
await this.requestManagementDbService.countByFilter(filter);
|
|
||||||
data[status] = count;
|
|
||||||
data.all += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "damage_expert") {
|
if (role === "damage_expert") {
|
||||||
const statuses = Object.values(ReqClaimStatus);
|
return this.countClaimByCaseStatus(claims as any[], clientId);
|
||||||
const data: Record<string, number> = { all: 0 };
|
|
||||||
|
|
||||||
// For damage_expert, we need to apply the same filtering as expert-claim service
|
|
||||||
if (actor) {
|
|
||||||
// Fetch all requests with the statuses that expert-claim service shows
|
|
||||||
// This matches the statuses in getClaimRequestsListForExpert
|
|
||||||
const relevantStatuses = [
|
|
||||||
ReqClaimStatus.UnChecked,
|
|
||||||
ReqClaimStatus.ReviewRequest,
|
|
||||||
ReqClaimStatus.CheckAgain,
|
|
||||||
ReqClaimStatus.CloseRequest,
|
|
||||||
ReqClaimStatus.InPersonVisit,
|
|
||||||
ReqClaimStatus.CheckedRequest,
|
|
||||||
ReqClaimStatus.PendingFactorValidation,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Fetch all requests with relevant statuses (matching expert-claim query)
|
|
||||||
const allRequests =
|
|
||||||
await this.claimRequestManagementDbService.findAllByStatus({
|
|
||||||
claimStatus: { $in: relevantStatuses },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter requests using the same logic as expert-claim service
|
|
||||||
const filteredRequests =
|
|
||||||
await this.filterClaimRequestsForExpert(allRequests, actor);
|
|
||||||
|
|
||||||
// Count by status from filtered results
|
|
||||||
for (const status of statuses) {
|
|
||||||
const count = filteredRequests.filter(
|
|
||||||
(r) => r.claimStatus === status,
|
|
||||||
).length;
|
|
||||||
data[status] = count;
|
|
||||||
data.all += count;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
|
|
||||||
for (const status of statuses) {
|
|
||||||
const filter = {
|
|
||||||
claimStatus: status,
|
|
||||||
userClientKey: new Types.ObjectId(client),
|
|
||||||
};
|
|
||||||
|
|
||||||
const count =
|
|
||||||
await this.claimRequestManagementDbService.countByFilter(filter);
|
|
||||||
data[status] = count;
|
|
||||||
data.all += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Company logic
|
|
||||||
if (role === "company") {
|
if (role === "company") {
|
||||||
const blameStatuses = Object.values(ReqBlameStatus);
|
return {
|
||||||
const claimStatuses = Object.values(ReqClaimStatus);
|
blame: this.countBlameByCaseStatus(blames as any[], clientId),
|
||||||
|
claim: this.countClaimByCaseStatus(claims as any[], clientId),
|
||||||
const blameData: Record<string, number> = { all: 0 };
|
};
|
||||||
const claimData: Record<string, number> = { all: 0 };
|
|
||||||
|
|
||||||
// Only count files where this company is FIRST party in Blame
|
|
||||||
for (const status of blameStatuses) {
|
|
||||||
const filter = {
|
|
||||||
blameStatus: status,
|
|
||||||
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
|
|
||||||
client,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const count =
|
|
||||||
await this.requestManagementDbService.countByFilter(filter);
|
|
||||||
blameData[status] = count;
|
|
||||||
blameData.all += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claims always use `userClientKey`
|
|
||||||
for (const status of claimStatuses) {
|
|
||||||
const filter = {
|
|
||||||
claimStatus: status,
|
|
||||||
userClientKey: new Types.ObjectId(client),
|
|
||||||
};
|
|
||||||
|
|
||||||
const count =
|
|
||||||
await this.claimRequestManagementDbService.countByFilter(filter);
|
|
||||||
claimData[status] = count;
|
|
||||||
claimData.all += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { blameData, claimData };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportCount(actor, client) {
|
/**
|
||||||
if (actor.role === "damage_expert") {
|
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
|
||||||
// Pass actor to apply filtering logic for damage_expert
|
* Company gets both blame and claim maps; expert / damage_expert get one map.
|
||||||
const data = await this.getAllRequestsCountByRole(
|
*/
|
||||||
actor.role,
|
async getDateFilteredRequestByStatus(
|
||||||
client,
|
role: string,
|
||||||
actor,
|
client: string | undefined,
|
||||||
);
|
start: Date,
|
||||||
return new DamageExpertAllRequestsCountReportDtoRs(data);
|
end: Date,
|
||||||
} else if (actor.role === "expert") {
|
) {
|
||||||
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
const clientId = this.clientObjectId(client);
|
||||||
return new ExpertAllRequestsCountReportDtoRs(data);
|
const [blames, claims] = await Promise.all([
|
||||||
} else {
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
// company
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
const damageExpertData = await this.getAllRequestsCountByRole(
|
]);
|
||||||
"damage_expert",
|
|
||||||
client,
|
const inRange = (r: any) => {
|
||||||
);
|
const createdAt = new Date(r.createdAt);
|
||||||
const expertData = await this.getAllRequestsCountByRole("expert", client);
|
return createdAt >= start && createdAt <= end;
|
||||||
return new CompanyAllRequestsCountReportDtoRs(
|
};
|
||||||
expertData,
|
|
||||||
damageExpertData,
|
if (role === "company") {
|
||||||
);
|
const blameOut: Record<string, number> = { all: 0 };
|
||||||
|
const claimOut: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(CaseStatus)) blameOut[s] = 0;
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0;
|
||||||
|
|
||||||
|
for (const r of blames as any[]) {
|
||||||
|
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in blameOut) blameOut[st]++;
|
||||||
|
else blameOut[st] = (blameOut[st] ?? 0) + 1;
|
||||||
|
blameOut.all++;
|
||||||
|
}
|
||||||
|
for (const r of claims as any[]) {
|
||||||
|
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in claimOut) claimOut[st]++;
|
||||||
|
else claimOut[st] = (claimOut[st] ?? 0) + 1;
|
||||||
|
claimOut.all++;
|
||||||
|
}
|
||||||
|
return { blame: blameOut, claim: claimOut };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "expert") {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(CaseStatus)) out[s] = 0;
|
||||||
|
for (const r of blames as any[]) {
|
||||||
|
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "damage_expert") {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) out[s] = 0;
|
||||||
|
for (const r of claims as any[]) {
|
||||||
|
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException("Unsupported role for reports");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportPerMonth(actor, client) {
|
async getAllRequestsReportCount(actor: any, client: string | undefined) {
|
||||||
const result = [];
|
requireActorClientKey(actor);
|
||||||
|
|
||||||
|
if (actor.role === "damage_expert") {
|
||||||
|
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
||||||
|
return new ClaimCaseStatusCountReportDtoRs(data as Record<string, number>);
|
||||||
|
}
|
||||||
|
if (actor.role === "expert") {
|
||||||
|
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
||||||
|
return new BlameCaseStatusCountReportDtoRs(data as Record<string, number>);
|
||||||
|
}
|
||||||
|
const companyPayload = await this.getAllRequestsCountByRole(
|
||||||
|
"company",
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
return new CompanyAllRequestsCountReportDtoRs(
|
||||||
|
(companyPayload as { blame: Record<string, number> }).blame,
|
||||||
|
(companyPayload as { claim: Record<string, number> }).claim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const result: any[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (actor.role === "company") {
|
for (let i = 0; i < 5; i++) {
|
||||||
for (let i = 0; i < 5; i++) {
|
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const monthEnd = new Date(
|
||||||
const monthEnd = new Date(
|
now.getFullYear(),
|
||||||
now.getFullYear(),
|
now.getMonth() - i + 1,
|
||||||
now.getMonth() - i + 1,
|
0,
|
||||||
0,
|
23,
|
||||||
23,
|
59,
|
||||||
59,
|
59,
|
||||||
59,
|
);
|
||||||
);
|
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
month: "long",
|
||||||
month: "long",
|
year: "numeric",
|
||||||
year: "numeric",
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const blameData = await this.getDateFilteredRequestByStatus(
|
let data: any;
|
||||||
"expert",
|
if (actor.role === "company") {
|
||||||
|
data = await this.getDateFilteredRequestByStatus(
|
||||||
|
"company",
|
||||||
client,
|
client,
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
const claimData = await this.getDateFilteredRequestByStatus(
|
} else {
|
||||||
"damage_expert",
|
data = await this.getDateFilteredRequestByStatus(
|
||||||
client,
|
|
||||||
monthStart,
|
|
||||||
monthEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
result.unshift({
|
|
||||||
stDate: monthStart,
|
|
||||||
enDate: monthEnd,
|
|
||||||
faLabel: label,
|
|
||||||
data: {
|
|
||||||
blame: blameData,
|
|
||||||
claim: claimData,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
||||||
const monthEnd = new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth() - i + 1,
|
|
||||||
0,
|
|
||||||
23,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
);
|
|
||||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
const data = await this.getDateFilteredRequestByStatus(
|
|
||||||
actor.role,
|
actor.role,
|
||||||
client,
|
client,
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
result.unshift({
|
|
||||||
stDate: monthStart,
|
|
||||||
enDate: monthEnd,
|
|
||||||
faLabel: label,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
result.unshift({
|
||||||
|
stDate: monthStart,
|
||||||
|
enDate: monthEnd,
|
||||||
|
faLabel: label,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllCheckedRequestsCount(actor, client) {
|
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
|
||||||
if (actor.role === "damage_expert" || actor.role === "expert") {
|
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
|
||||||
return this.getAllCheckedRequestsCountFn(actor.role, client);
|
return this.getAllRequestsReportCount(actor, client);
|
||||||
} else {
|
|
||||||
const blame = await this.getAllCheckedRequestsCountFn("expert", client);
|
|
||||||
const claim = await this.getAllCheckedRequestsCountFn(
|
|
||||||
"damage_expert",
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
return { blame, claim };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
|
import {
|
||||||
|
CarBodyFormDto,
|
||||||
|
DescriptionDto,
|
||||||
|
InitialFormDto,
|
||||||
|
} from "./create-request-management.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 (IN_PERSON): single expert description/location for the scene.
|
||||||
|
*/
|
||||||
|
export class ExpertCompleteCarBodyFormV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "First party phone number",
|
||||||
|
example: "09123456789",
|
||||||
|
})
|
||||||
|
firstPartyPhoneNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InitialFormDto })
|
||||||
|
firstPartyInitialForm: InitialFormDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: CarBodyFormDto })
|
||||||
|
carBodyForm: CarBodyFormDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AddPlateDto })
|
||||||
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: DescriptionDto })
|
||||||
|
expertDescription: DescriptionDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { LocationDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 (IN_PERSON): one scene location provided by expert.
|
||||||
|
*/
|
||||||
|
export class ExpertCompleteLocationV2Dto {
|
||||||
|
@ApiProperty({ type: LocationDto })
|
||||||
|
location: LocationDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
|
import { DescriptionDto, InitialFormDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 (IN_PERSON): second party inputs without separate description/location.
|
||||||
|
*/
|
||||||
|
export class ExpertSecondPartyInfoV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Second party phone number",
|
||||||
|
example: "09123456789",
|
||||||
|
})
|
||||||
|
phoneNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InitialFormDto })
|
||||||
|
initialForm: InitialFormDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AddPlateDto })
|
||||||
|
plate: AddPlateDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 (IN_PERSON): single expert description shared for the whole scene.
|
||||||
|
*/
|
||||||
|
export class ExpertCompleteThirdPartyFormV2Dto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "First party phone number",
|
||||||
|
example: "09123456789",
|
||||||
|
})
|
||||||
|
firstPartyPhoneNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InitialFormDto })
|
||||||
|
firstPartyInitialForm: InitialFormDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AddPlateDto })
|
||||||
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ExpertSecondPartyInfoV2Dto })
|
||||||
|
secondParty: ExpertSecondPartyInfoV2Dto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: DescriptionDto })
|
||||||
|
expertDescription: DescriptionDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Phone number of the guilty party. Must match either first or second party phone number.",
|
||||||
|
example: "09123456789",
|
||||||
|
})
|
||||||
|
guiltyPartyPhoneNumber: string;
|
||||||
|
}
|
||||||
@@ -27,14 +27,14 @@ import { Roles } from "src/decorators/roles.decorator";
|
|||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||||
import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto";
|
import { ExpertCompleteThirdPartyFormV2Dto } from "./dto/expert-complete-third-party-form.v2.dto";
|
||||||
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto";
|
import { ExpertCompleteCarBodyFormV2Dto } from "./dto/expert-complete-car-body-form.v2.dto";
|
||||||
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||||
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
|
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
|
||||||
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
||||||
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||||
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||||
import { ExpertCompleteLocationsDto } from "./dto/expert-complete-locations.dto";
|
import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto";
|
||||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
@@ -225,11 +225,11 @@ export class ExpertInitiatedV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V2] Submit all blame data (IN_PERSON)",
|
summary: "[V2] Submit all blame data (IN_PERSON)",
|
||||||
description:
|
description:
|
||||||
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT locations. After this, call add-locations to submit lat/lon and move workflow to WAITING_FOR_SIGNATURES.",
|
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. All nested fields are listed so you can fill or test without guessing property names.",
|
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.",
|
||||||
examples: {
|
examples: {
|
||||||
THIRD_PARTY: {
|
THIRD_PARTY: {
|
||||||
summary: "THIRD_PARTY",
|
summary: "THIRD_PARTY",
|
||||||
@@ -253,7 +253,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 13770624,
|
insurerBirthday: 13770624,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
firstPartyDescription: { desc: "توضیح حادثه طرف اول" },
|
expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" },
|
||||||
secondParty: {
|
secondParty: {
|
||||||
phoneNumber: "09187654321",
|
phoneNumber: "09187654321",
|
||||||
initialForm: {
|
initialForm: {
|
||||||
@@ -273,7 +273,6 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 13700720,
|
insurerBirthday: 13700720,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
description: { desc: "توضیح حادثه طرف دوم" },
|
|
||||||
},
|
},
|
||||||
guiltyPartyPhoneNumber: "09123456789",
|
guiltyPartyPhoneNumber: "09123456789",
|
||||||
},
|
},
|
||||||
@@ -302,7 +301,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
insurerBirthday: 1370,
|
insurerBirthday: 1370,
|
||||||
driverBirthday: "1370-01-01",
|
driverBirthday: "1370-01-01",
|
||||||
},
|
},
|
||||||
firstPartyDescription: {
|
expertDescription: {
|
||||||
desc: "توضیح حادثه",
|
desc: "توضیح حادثه",
|
||||||
accidentDate: "2025-01-15",
|
accidentDate: "2025-01-15",
|
||||||
accidentTime: "14:30",
|
accidentTime: "14:30",
|
||||||
@@ -319,7 +318,7 @@ export class ExpertInitiatedV2Controller {
|
|||||||
async completeBlameDataV2(
|
async completeBlameDataV2(
|
||||||
@CurrentUser() expert: any,
|
@CurrentUser() expert: any,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@Body() formData: any,
|
@Body() formData: ExpertCompleteThirdPartyFormV2Dto | ExpertCompleteCarBodyFormV2Dto,
|
||||||
) {
|
) {
|
||||||
return this.requestManagementService.expertCompleteBlameDataV2(
|
return this.requestManagementService.expertCompleteBlameDataV2(
|
||||||
expert,
|
expert,
|
||||||
@@ -330,25 +329,18 @@ export class ExpertInitiatedV2Controller {
|
|||||||
|
|
||||||
@Post("add-locations/:requestId")
|
@Post("add-locations/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V2] Submit location(s) for expert-initiated IN_PERSON blame",
|
summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame",
|
||||||
description:
|
description:
|
||||||
"Submit first party (and second party for THIRD_PARTY) location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
|
"Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: ExpertCompleteLocationsDto,
|
type: ExpertCompleteLocationV2Dto,
|
||||||
examples: {
|
examples: {
|
||||||
THIRD_PARTY: {
|
scene: {
|
||||||
summary: "THIRD_PARTY locations",
|
summary: "One scene location (all IN_PERSON types)",
|
||||||
value: {
|
value: {
|
||||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
location: { lat: 35.6892, lon: 51.389 },
|
||||||
secondPartyLocation: { lat: 35.7001, lon: 51.4102 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CAR_BODY: {
|
|
||||||
summary: "CAR_BODY location",
|
|
||||||
value: {
|
|
||||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -357,12 +349,12 @@ export class ExpertInitiatedV2Controller {
|
|||||||
async addLocationsV2(
|
async addLocationsV2(
|
||||||
@CurrentUser() expert: any,
|
@CurrentUser() expert: any,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@Body() dto: ExpertCompleteLocationsDto,
|
@Body() dto: ExpertCompleteLocationV2Dto,
|
||||||
) {
|
) {
|
||||||
return this.requestManagementService.expertAddLocationsForBlameV2(
|
return this.requestManagementService.expertAddLocationsForBlameV2(
|
||||||
expert,
|
expert,
|
||||||
requestId,
|
requestId,
|
||||||
dto,
|
dto as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3938,10 +3938,19 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
async getAllBlameRequestsV2(user: any): Promise<any> {
|
async getAllBlameRequestsV2(user: any): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
const userIdFilter =
|
||||||
|
user?.sub && Types.ObjectId.isValid(user.sub)
|
||||||
|
? { "parties.person.userId": new Types.ObjectId(user.sub) }
|
||||||
|
: null;
|
||||||
|
const phoneFilter = user?.username
|
||||||
|
? { "parties.person.phoneNumber": user.username }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const filters = [userIdFilter, phoneFilter].filter(Boolean);
|
||||||
|
if (filters.length === 0) return [];
|
||||||
|
|
||||||
const requests = await this.blameRequestDbService.find(
|
const requests = await this.blameRequestDbService.find(
|
||||||
{
|
filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any),
|
||||||
"parties.person.userId": new Types.ObjectId(user.sub),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
select: "requestNo type status blameStatus createdAt updatedAt parties",
|
select: "requestNo type status blameStatus createdAt updatedAt parties",
|
||||||
}
|
}
|
||||||
@@ -3949,7 +3958,9 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
const enriched = requests.map((req: any) => {
|
const enriched = requests.map((req: any) => {
|
||||||
const party = req.parties.find(
|
const party = req.parties.find(
|
||||||
(p: any) => String(p.person.userId) === String(user.sub)
|
(p: any) =>
|
||||||
|
(p?.person?.userId && String(p.person.userId) === String(user.sub)) ||
|
||||||
|
(p?.person?.phoneNumber && p.person.phoneNumber === user?.username),
|
||||||
);
|
);
|
||||||
|
|
||||||
const obj = req.toObject();
|
const obj = req.toObject();
|
||||||
@@ -4105,6 +4116,9 @@ export class RequestManagementService {
|
|||||||
if (!formData.carBodyForm) {
|
if (!formData.carBodyForm) {
|
||||||
throw new BadRequestException("carBodyForm is required.");
|
throw new BadRequestException("carBodyForm is required.");
|
||||||
}
|
}
|
||||||
|
if (!formData?.expertDescription?.desc) {
|
||||||
|
throw new BadRequestException("expertDescription.desc is required.");
|
||||||
|
}
|
||||||
|
|
||||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||||
formData.firstPartyPhoneNumber,
|
formData.firstPartyPhoneNumber,
|
||||||
@@ -4176,12 +4190,12 @@ export class RequestManagementService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
statement: {
|
statement: {
|
||||||
description: formData.firstPartyDescription?.desc,
|
description: formData.expertDescription?.desc,
|
||||||
accidentDate: formData.firstPartyDescription?.accidentDate,
|
accidentDate: formData.expertDescription?.accidentDate,
|
||||||
accidentTime: formData.firstPartyDescription?.accidentTime,
|
accidentTime: formData.expertDescription?.accidentTime,
|
||||||
weatherCondition: formData.firstPartyDescription?.weatherCondition,
|
weatherCondition: formData.expertDescription?.weatherCondition,
|
||||||
roadCondition: formData.firstPartyDescription?.roadCondition,
|
roadCondition: formData.expertDescription?.roadCondition,
|
||||||
lightCondition: formData.firstPartyDescription?.lightCondition,
|
lightCondition: formData.expertDescription?.lightCondition,
|
||||||
},
|
},
|
||||||
location: req.parties?.[0]?.location,
|
location: req.parties?.[0]?.location,
|
||||||
carBodyFirstForm: {
|
carBodyFirstForm: {
|
||||||
@@ -4250,6 +4264,9 @@ export class RequestManagementService {
|
|||||||
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
|
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
|
||||||
throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required.");
|
throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required.");
|
||||||
}
|
}
|
||||||
|
if (!formData?.expertDescription?.desc) {
|
||||||
|
throw new BadRequestException("expertDescription.desc is required.");
|
||||||
|
}
|
||||||
|
|
||||||
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
const firstPartyUserId = await this.getOrCreateUserByPhoneNumber(
|
||||||
formData.firstPartyPhoneNumber,
|
formData.firstPartyPhoneNumber,
|
||||||
@@ -4320,7 +4337,7 @@ export class RequestManagementService {
|
|||||||
firstPartyUserId,
|
firstPartyUserId,
|
||||||
formData.firstPartyInitialForm,
|
formData.firstPartyInitialForm,
|
||||||
formData.firstPartyPlate,
|
formData.firstPartyPlate,
|
||||||
formData.firstPartyDescription?.desc || "",
|
formData.expertDescription?.desc || "",
|
||||||
PartyRole.FIRST,
|
PartyRole.FIRST,
|
||||||
);
|
);
|
||||||
const secondParty = await buildPartyFromForm(
|
const secondParty = await buildPartyFromForm(
|
||||||
@@ -4328,7 +4345,7 @@ export class RequestManagementService {
|
|||||||
secondPartyUserId,
|
secondPartyUserId,
|
||||||
formData.secondParty.initialForm,
|
formData.secondParty.initialForm,
|
||||||
formData.secondParty.plate,
|
formData.secondParty.plate,
|
||||||
formData.secondParty.description?.desc || "",
|
formData.expertDescription?.desc || "",
|
||||||
PartyRole.SECOND,
|
PartyRole.SECOND,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4386,7 +4403,7 @@ export class RequestManagementService {
|
|||||||
async expertAddLocationsForBlameV2(
|
async expertAddLocationsForBlameV2(
|
||||||
expert: any,
|
expert: any,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
dto: { firstPartyLocation: LocationDto; secondPartyLocation?: LocationDto },
|
dto: { location: LocationDto },
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const req = await this.blameRequestDbService.findById(requestId);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Request not found");
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
@@ -4402,21 +4419,10 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||||
if (firstIdx === -1) throw new BadRequestException("First party not found");
|
if (firstIdx === -1) throw new BadRequestException("First party not found");
|
||||||
if (!dto?.firstPartyLocation) {
|
if (!dto?.location) {
|
||||||
throw new BadRequestException("firstPartyLocation is required");
|
throw new BadRequestException("location is required");
|
||||||
}
|
|
||||||
req.parties[firstIdx].location = dto.firstPartyLocation as any;
|
|
||||||
|
|
||||||
if (req.type === BlameRequestType.THIRD_PARTY) {
|
|
||||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
|
||||||
if (secondIdx === -1) throw new BadRequestException("Second party not found");
|
|
||||||
if (!dto?.secondPartyLocation) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"secondPartyLocation is required for THIRD_PARTY",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
req.parties[secondIdx].location = dto.secondPartyLocation as any;
|
|
||||||
}
|
}
|
||||||
|
req.parties[firstIdx].location = dto.location as any;
|
||||||
|
|
||||||
const completed = Array.isArray(req.workflow?.completedSteps)
|
const completed = Array.isArray(req.workflow?.completedSteps)
|
||||||
? req.workflow.completedSteps
|
? req.workflow.completedSteps
|
||||||
@@ -4424,12 +4430,6 @@ export class RequestManagementService {
|
|||||||
if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) {
|
if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) {
|
||||||
completed.push(WorkflowStep.FIRST_LOCATION as any);
|
completed.push(WorkflowStep.FIRST_LOCATION as any);
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
req.type === BlameRequestType.THIRD_PARTY &&
|
|
||||||
!completed.includes(WorkflowStep.SECOND_LOCATION as any)
|
|
||||||
) {
|
|
||||||
completed.push(WorkflowStep.SECOND_LOCATION as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
req.workflow = {
|
req.workflow = {
|
||||||
...(req.workflow || {}),
|
...(req.workflow || {}),
|
||||||
@@ -4449,8 +4449,7 @@ export class RequestManagementService {
|
|||||||
actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert",
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
hasFirstLocation: true,
|
hasLocation: true,
|
||||||
hasSecondLocation: req.type === BlameRequestType.THIRD_PARTY,
|
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -4533,6 +4532,12 @@ export class RequestManagementService {
|
|||||||
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
|
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
|
||||||
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
|
if (firstPartyIndex === -1) throw new BadRequestException("First party not found");
|
||||||
const firstParty = req.parties[firstPartyIndex];
|
const firstParty = req.parties[firstPartyIndex];
|
||||||
|
if (
|
||||||
|
Array.isArray(firstParty?.evidence?.voices) &&
|
||||||
|
firstParty.evidence.voices.length > 0
|
||||||
|
) {
|
||||||
|
throw new ConflictException("Voice already uploaded for this file");
|
||||||
|
}
|
||||||
|
|
||||||
const voiceDocument = await this.blameVoiceDbService.create({
|
const voiceDocument = await this.blameVoiceDbService.create({
|
||||||
fileName: voiceFile.filename,
|
fileName: voiceFile.filename,
|
||||||
|
|||||||
Reference in New Issue
Block a user