forked from Yara724/api
Compare commits
46 Commits
eef305c42e
...
908292b0c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 908292b0c3 | |||
| 55ec6f1fd1 | |||
| d33cff8438 | |||
| 0bb13f4596 | |||
| 70306d42e0 | |||
| d9dc4ecdff | |||
| f66fa5d7b4 | |||
| 6429cb0f2b | |||
| 6f120b0066 | |||
| 8419ec06ae | |||
| def4023185 | |||
| 40606fecf1 | |||
| c567f93e85 | |||
| a9846095c1 | |||
| b6f0e3c821 | |||
| bffb8a3b97 | |||
| 715a9f2467 | |||
| 3813c2b3e3 | |||
| ebf8a9a624 | |||
| 993d809de2 | |||
| 80ef885d3b | |||
|
|
3f608f63f1 | ||
| 6019c9e954 | |||
|
|
8ae6f2c91b | ||
|
|
f999313476 | ||
|
|
98f1d2caf5 | ||
|
|
bbd83da2d5 | ||
|
|
9296795166 | ||
|
|
f456443342 | ||
|
|
bcedd8c6f3 | ||
| 8caf13cf18 | |||
|
|
e90f8dc33c | ||
| 4c4b1a1db7 | |||
|
|
c2f996cc28 | ||
| 362e02ddc4 | |||
|
|
4b0e8a3547 | ||
|
|
885678df7d | ||
| 8dffc46357 | |||
|
|
b5b3b722c6 | ||
|
|
4f8cb43883 | ||
|
|
6c2d178686 | ||
| 9854a58282 | |||
|
|
7184142137 | ||
| d45975b6a7 | |||
|
|
05c5b70b4d | ||
|
|
5d2227b00b |
@@ -1,5 +1,55 @@
|
||||
import { ResendItemType } from "./resendItemType.enum";
|
||||
|
||||
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
|
||||
|
||||
/**
|
||||
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
|
||||
*/
|
||||
export function normalizeResendRequestedItemKey(raw: string): string | null {
|
||||
const t = String(raw ?? "").trim();
|
||||
if (!t) return null;
|
||||
if (RESEND_ITEM_VALUES.has(t)) return t;
|
||||
const lower = t.toLowerCase();
|
||||
for (const v of RESEND_ITEM_VALUES) {
|
||||
if (v.toLowerCase() === lower) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deduplicated list of valid requested item keys. */
|
||||
export function normalizeResendRequestedItemsList(
|
||||
items: string[] | undefined | null,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of items || []) {
|
||||
const c = normalizeResendRequestedItemKey(String(raw));
|
||||
if (c && !seen.has(c)) {
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
|
||||
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
|
||||
*/
|
||||
export function cloneResendUploadedDocuments(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (raw == null || typeof raw !== "object") return {};
|
||||
if (raw instanceof Map) {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const [k, v] of raw.entries()) {
|
||||
o[String(k)] = v;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
||||
|
||||
@@ -11,7 +61,8 @@ export function getResendItemInputKind(item: string): ResendItemInputKind {
|
||||
}
|
||||
|
||||
export function buildResendItemsWithUi(requestedItems: string[]) {
|
||||
return requestedItems.map((item) => ({
|
||||
const normalized = normalizeResendRequestedItemsList(requestedItems);
|
||||
return normalized.map((item) => ({
|
||||
item,
|
||||
inputKind: getResendItemInputKind(item),
|
||||
}));
|
||||
|
||||
@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
|
||||
CarCertificate = "carCertificate",
|
||||
DrivingLicense = "drivingLicense",
|
||||
CarGreenCard = "carGreenCard",
|
||||
}
|
||||
Plate = "plate",
|
||||
CarPlate = "carPlate",
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
|
||||
username: process.env.AI_USERNAME,
|
||||
password: process.env.AI_PASSWORD,
|
||||
},
|
||||
timeout: 30000, // 30 second timeout
|
||||
timeout: 1000, // 30 second timeout
|
||||
};
|
||||
|
||||
private get profileOptions(): AxiosRequestConfig {
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { diskStorage } from "multer";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
|
||||
) {}
|
||||
|
||||
@ApiParam({ name: "blameId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/:blameId")
|
||||
async createClaimRequest(
|
||||
@Param("blameId") requestId: string,
|
||||
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
|
||||
}
|
||||
|
||||
@ApiBody({ type: CarDamagePartDto })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-part-damage/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async carPartDamage(
|
||||
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/car-other-part")
|
||||
async getCarOtherParts() {
|
||||
const carOtherPart = await readFile(
|
||||
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-other-part-damage/:claimRequestID")
|
||||
async carOtherPartDamage(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(
|
||||
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("car-part-image-required/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description: "Type of required document to upload",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("upload-required-document/:claimRequestID")
|
||||
async uploadRequiredDocument(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
|
||||
name: "partId",
|
||||
description: "The ID of the specific car part being photographed.",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||
async captureCarPartDamage(
|
||||
@Param("partId") partId: string,
|
||||
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("car-capture/:claimRequestID")
|
||||
async captureVideoCapture(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("requests/")
|
||||
async getRequest(@CurrentUser() currentUser) {
|
||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
myRequests(
|
||||
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
|
||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/reply/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@UseInterceptors(
|
||||
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/resend/:claimRequestId/objection")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("application/json")
|
||||
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/resend/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
|
||||
* User satisfaction rating for a completed claim file.
|
||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||
*/
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
|
||||
|
||||
@ApiBody({ type: InPersonVisitDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(
|
||||
@Param("id") requestId: string,
|
||||
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||
|
||||
@@ -67,6 +67,11 @@ import {
|
||||
import { PublicIdService } from "src/utils/public-id/public-id.service";
|
||||
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||||
@@ -79,6 +84,22 @@ import {
|
||||
normalizeResendPartKeys,
|
||||
partKeyAllowedForExpertResend,
|
||||
} from "src/helpers/claim-expert-resend";
|
||||
import {
|
||||
canonicalizeClaimCarAngleKey,
|
||||
getClaimCarAngleCaptureBlob,
|
||||
getDamagedPartCaptureBlob,
|
||||
hasClaimCarAngleCapture,
|
||||
hasDamagedPartCapture,
|
||||
legacyAngleDamagedPartFieldKeysToUnset,
|
||||
legacyDamagedPartTitleEnFromPartKey,
|
||||
resolveCanonicalDamagedPartStorageKey,
|
||||
type ClaimCarAngleKey,
|
||||
} from "src/helpers/claim-car-angle-media";
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OUTER_PARTS_BY_CAR_TYPE,
|
||||
OuterPartCatalogItem,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
|
||||
@Injectable()
|
||||
export class ClaimRequestManagementService {
|
||||
@@ -117,6 +138,7 @@ export class ClaimRequestManagementService {
|
||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly branchDbService: BranchDbService,
|
||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||
private readonly publicIdService: PublicIdService,
|
||||
@@ -126,6 +148,26 @@ export class ClaimRequestManagementService {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] {
|
||||
if (carType) {
|
||||
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
|
||||
...p,
|
||||
carType,
|
||||
}));
|
||||
}
|
||||
const out: OuterPartCatalogItem[] = [];
|
||||
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
|
||||
const t = type as ClaimVehicleTypeV2;
|
||||
for (const p of items) {
|
||||
out.push({ ...p, carType: t });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
if (a.carType === b.carType) return a.id - b.id;
|
||||
return String(a.carType).localeCompare(String(b.carType));
|
||||
});
|
||||
}
|
||||
|
||||
private userDamageDetail(blRequest: RequestManagementModel) {
|
||||
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
|
||||
switch (blRequest.expertSubmitReply.guiltyUserId) {
|
||||
@@ -1869,6 +1911,90 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 response mapper:
|
||||
* - convert factorLink ObjectId -> public URL
|
||||
* - enrich daghi.branchId with branchName when available
|
||||
*/
|
||||
private async mapEvaluationForClient(evaluation: any): Promise<any | undefined> {
|
||||
if (!evaluation) return undefined;
|
||||
|
||||
const mapReply = async (reply: any) => {
|
||||
if (!reply) return reply;
|
||||
|
||||
const mapped = {
|
||||
...(typeof reply?.toObject === "function" ? reply.toObject() : reply),
|
||||
};
|
||||
const parts = Array.isArray(mapped.parts) ? mapped.parts : [];
|
||||
|
||||
const factorIds = new Set<string>();
|
||||
const branchIds = new Set<string>();
|
||||
for (const p of parts) {
|
||||
if (p?.factorLink && Types.ObjectId.isValid(String(p.factorLink))) {
|
||||
factorIds.add(String(p.factorLink));
|
||||
}
|
||||
const daghi = p?.daghi;
|
||||
const rawBranchId =
|
||||
daghi && typeof daghi === "object" ? (daghi as any).branchId : undefined;
|
||||
if (rawBranchId && Types.ObjectId.isValid(String(rawBranchId))) {
|
||||
branchIds.add(String(rawBranchId));
|
||||
}
|
||||
}
|
||||
|
||||
const factorMap = new Map<string, string>();
|
||||
await Promise.all(
|
||||
Array.from(factorIds).map(async (id) => {
|
||||
const factorDoc = await this.claimFactorsImageDbService.findById(id);
|
||||
if (factorDoc?.path) {
|
||||
factorMap.set(id, buildFileLink(factorDoc.path));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const branchMap = new Map<string, string>();
|
||||
await Promise.all(
|
||||
Array.from(branchIds).map(async (id) => {
|
||||
const branch = await this.branchDbService.findById(id);
|
||||
if (branch?.name) {
|
||||
branchMap.set(id, branch.name);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
mapped.parts = parts.map((part: any) => {
|
||||
const nextPart = { ...part };
|
||||
|
||||
if (nextPart.factorLink && Types.ObjectId.isValid(String(nextPart.factorLink))) {
|
||||
const link = factorMap.get(String(nextPart.factorLink));
|
||||
if (link) nextPart.factorLink = link;
|
||||
}
|
||||
|
||||
const daghi = nextPart.daghi;
|
||||
if (daghi && typeof daghi === "object" && !Array.isArray(daghi)) {
|
||||
const branchId = (daghi as any).branchId;
|
||||
const branchName =
|
||||
branchId && Types.ObjectId.isValid(String(branchId))
|
||||
? branchMap.get(String(branchId))
|
||||
: undefined;
|
||||
nextPart.daghi = {
|
||||
...daghi,
|
||||
...(branchName ? { branchName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return nextPart;
|
||||
});
|
||||
|
||||
return mapped;
|
||||
};
|
||||
|
||||
return {
|
||||
...evaluation,
|
||||
damageExpertReply: await mapReply(evaluation.damageExpertReply),
|
||||
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
|
||||
};
|
||||
}
|
||||
|
||||
async submitUserReply(
|
||||
requestId: string,
|
||||
body: UserCommentDto,
|
||||
@@ -2158,6 +2284,7 @@ export class ClaimRequestManagementService {
|
||||
return {
|
||||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||||
factorId: factorRecord._id,
|
||||
factorLink: buildFileLink(file.path),
|
||||
url: buildFileLink(file.path),
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -2577,11 +2704,20 @@ export class ClaimRequestManagementService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.damageExpertDbService.updateStats(
|
||||
actorDetail.sub,
|
||||
"handled",
|
||||
requestId,
|
||||
);
|
||||
if (
|
||||
Types.ObjectId.isValid(String(actorDetail.sub)) &&
|
||||
Types.ObjectId.isValid(String(request.userClientKey)) &&
|
||||
Types.ObjectId.isValid(String(requestId))
|
||||
) {
|
||||
await this.expertFileActivityDbService.recordEvent({
|
||||
expertId: String(actorDetail.sub),
|
||||
tenantId: String(request.userClientKey),
|
||||
fileId: String(requestId),
|
||||
fileType: ExpertFileKind.CLAIM,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
idempotencyKey: `claim:${requestId}:handled:inperson:${actorDetail.sub}`,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -2680,6 +2816,27 @@ export class ClaimRequestManagementService {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */
|
||||
private collectBranchIdsFromClaimExpertReply(reply: {
|
||||
parts?: unknown[];
|
||||
}): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const parts = reply?.parts;
|
||||
if (!Array.isArray(parts)) {
|
||||
return ids;
|
||||
}
|
||||
for (const p of parts) {
|
||||
const d = (p as { daghi?: unknown })?.daghi;
|
||||
if (d && typeof d === "object" && d !== null && "branchId" in d) {
|
||||
const bid = (d as { branchId?: unknown }).branchId;
|
||||
if (bid != null && Types.ObjectId.isValid(String(bid))) {
|
||||
ids.add(String(bid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
|
||||
*/
|
||||
@@ -3719,8 +3876,37 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Convert selected parts to storage format (simple array of strings)
|
||||
// Backward compatibility: some clients may still send legacy `carPartDamage` object.
|
||||
// 5. Validate by selected car type and resolve selected parts by ids/keys
|
||||
const selectedCarType = (body as any)?.carType as
|
||||
| ClaimVehicleTypeV2
|
||||
| undefined;
|
||||
if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) {
|
||||
throw new BadRequestException(
|
||||
"Vehicle type is required before selecting outer parts.",
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType];
|
||||
const byId = new Map<number, OuterPartCatalogItem>(catalog.map((p) => [p.id, p]));
|
||||
const byKey = new Map<string, OuterPartCatalogItem>(catalog.map((p) => [p.key, p]));
|
||||
|
||||
const selectedFromIds: OuterPartCatalogItem[] = [];
|
||||
if (
|
||||
Array.isArray((body as any)?.selectedPartIds) &&
|
||||
(body as any).selectedPartIds.length > 0
|
||||
) {
|
||||
for (const id of (body as any).selectedPartIds as number[]) {
|
||||
const item = byId.get(id);
|
||||
if (!item) {
|
||||
throw new BadRequestException(
|
||||
`Invalid outer part id for ${selectedCarType}: ${id}`,
|
||||
);
|
||||
}
|
||||
selectedFromIds.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility with selectedParts + legacy carPartDamage
|
||||
let selectedParts = Array.isArray((body as any)?.selectedParts)
|
||||
? ((body as any).selectedParts as string[])
|
||||
: [];
|
||||
@@ -3732,17 +3918,52 @@ export class ClaimRequestManagementService {
|
||||
(body as any).carPartDamage as CarDamagePartDto,
|
||||
);
|
||||
}
|
||||
if (selectedParts.length === 0) {
|
||||
const selectedFromKeys: OuterPartCatalogItem[] = [];
|
||||
for (const key of selectedParts) {
|
||||
const item = byKey.get(key);
|
||||
if (!item) {
|
||||
throw new BadRequestException(
|
||||
`Invalid outer part key for ${selectedCarType}: ${key}`,
|
||||
);
|
||||
}
|
||||
selectedFromKeys.push(item);
|
||||
}
|
||||
|
||||
const selectedItems =
|
||||
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
|
||||
if (selectedItems.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"selectedParts is required and must contain at least one item",
|
||||
"selectedPartIds or selectedParts is required and must contain at least one item",
|
||||
);
|
||||
}
|
||||
|
||||
// At most two non-top sides allowed
|
||||
const sideSet = new Set(
|
||||
selectedItems
|
||||
.map((p) => p.side)
|
||||
.filter((s) => s !== "top"),
|
||||
);
|
||||
if (sideSet.size > 2) {
|
||||
throw new BadRequestException(
|
||||
`At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
selectedParts = selectedItems.map((p) => p.key);
|
||||
const selectedPartIds = selectedItems.map((p) => p.id);
|
||||
const selectedOuterParts = selectedItems.map((p) => ({
|
||||
id: p.id,
|
||||
key: p.key,
|
||||
side: p.side,
|
||||
}));
|
||||
|
||||
// 6. Update claim case with selected parts and move to next step
|
||||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||||
claimRequestId,
|
||||
{
|
||||
'damage.selectedParts': selectedParts,
|
||||
'damage.selectedOuterParts': selectedOuterParts,
|
||||
'vehicle.carType': selectedCarType,
|
||||
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||
@@ -3759,6 +3980,8 @@ export class ClaimRequestManagementService {
|
||||
metadata: {
|
||||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||
selectedParts: selectedParts,
|
||||
selectedPartIds,
|
||||
carType: selectedCarType,
|
||||
partsCount: selectedParts?.length,
|
||||
description: `User selected ${selectedParts?.length} damaged outer parts`,
|
||||
},
|
||||
@@ -3780,6 +4003,7 @@ export class ClaimRequestManagementService {
|
||||
claimRequestId: updatedClaim._id.toString(),
|
||||
publicId: updatedClaim.publicId,
|
||||
selectedParts: selectedParts,
|
||||
selectedPartIds,
|
||||
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
|
||||
@@ -4024,24 +4248,28 @@ export class ClaimRequestManagementService {
|
||||
throw new ForbiddenException('Only the claim owner can view capture requirements');
|
||||
}
|
||||
|
||||
// Helper to get part labels
|
||||
// Build car-type aware outer-parts lookup (complete source: static catalog)
|
||||
const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const catalogForType =
|
||||
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||||
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
|
||||
: this.getOuterPartsCatalogV2();
|
||||
const catalogByKey = new Map<string, OuterPartCatalogItem>();
|
||||
for (const item of catalogForType) {
|
||||
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
|
||||
}
|
||||
|
||||
const getPartLabel = (key: string): { fa: string; en: string } => {
|
||||
const labels = {
|
||||
hood: { fa: 'کاپوت', en: 'Hood' },
|
||||
trunk: { fa: 'صندوق عقب', en: 'Trunk' },
|
||||
roof: { fa: 'سقف', en: 'Roof' },
|
||||
front_right_door: { fa: 'درب جلو راست', en: 'Front Right Door' },
|
||||
front_left_door: { fa: 'درب جلو چپ', en: 'Front Left Door' },
|
||||
rear_right_door: { fa: 'درب عقب راست', en: 'Rear Right Door' },
|
||||
rear_left_door: { fa: 'درب عقب چپ', en: 'Rear Left Door' },
|
||||
front_bumper: { fa: 'سپر جلو', en: 'Front Bumper' },
|
||||
rear_bumper: { fa: 'سپر عقب', en: 'Rear Bumper' },
|
||||
front_right_fender: { fa: 'گلگیر جلو راست', en: 'Front Right Fender' },
|
||||
front_left_fender: { fa: 'گلگیر جلو چپ', en: 'Front Left Fender' },
|
||||
rear_right_fender: { fa: 'گلگیر عقب راست', en: 'Rear Right Fender' },
|
||||
rear_left_fender: { fa: 'گلگیر عقب چپ', en: 'Rear Left Fender' },
|
||||
};
|
||||
return labels[key] || { fa: key, en: key };
|
||||
const catalogItem = catalogByKey.get(key);
|
||||
if (catalogItem) {
|
||||
const en = key
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return { fa: catalogItem.titleFa, en };
|
||||
}
|
||||
return { fa: key, en: key };
|
||||
};
|
||||
|
||||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
||||
@@ -4090,18 +4318,54 @@ export class ClaimRequestManagementService {
|
||||
key: angle.key,
|
||||
label_fa: angle.label_fa,
|
||||
label_en: angle.label_en,
|
||||
captured: !!hasCapture(claimCase.media?.carAngles as any, angle.key),
|
||||
captured: hasClaimCarAngleCapture(
|
||||
claimCase.media?.carAngles as any,
|
||||
claimCase.media?.damagedParts as any,
|
||||
angle.key as ClaimCarAngleKey,
|
||||
),
|
||||
}));
|
||||
|
||||
// Damaged parts from selected parts
|
||||
const selectedParts = claimCase.damage?.selectedParts || [];
|
||||
const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts)
|
||||
? claimCase.damage.selectedOuterParts
|
||||
: [];
|
||||
const selectedOuterPartByKey = new Map<
|
||||
string,
|
||||
{ id?: number; side?: string }
|
||||
>(
|
||||
selectedOuterParts.map((p) => [
|
||||
p.key,
|
||||
{
|
||||
id: p.id,
|
||||
side: p.side,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const damagedParts = selectedParts.map(partKey => {
|
||||
const label = getPartLabel(partKey);
|
||||
const selectedMeta = selectedOuterPartByKey.get(partKey);
|
||||
const catalogMeta = catalogByKey.get(partKey);
|
||||
const side = selectedMeta?.side ?? catalogMeta?.side;
|
||||
const sideFaMap: Record<string, string> = {
|
||||
left: "چپ",
|
||||
right: "راست",
|
||||
front: "جلو",
|
||||
back: "عقب",
|
||||
top: "بالا",
|
||||
};
|
||||
const sideFa = side ? sideFaMap[side] : undefined;
|
||||
return {
|
||||
id: selectedMeta?.id ?? catalogMeta?.id,
|
||||
key: partKey,
|
||||
label_fa: label.fa,
|
||||
side,
|
||||
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa,
|
||||
label_en: label.en,
|
||||
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
|
||||
captured: hasDamagedPartCapture(
|
||||
claimCase.media?.damagedParts as any,
|
||||
partKey,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4489,8 +4753,24 @@ export class ClaimRequestManagementService {
|
||||
`Part "${body.captureKey}" was not requested by the damage expert for this resend.`,
|
||||
);
|
||||
}
|
||||
if (canonicalizeClaimCarAngleKey(body.captureKey)) {
|
||||
throw new BadRequestException(
|
||||
"During expert resend only damaged-part photos are allowed, not car angles (front/back/left/right).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey);
|
||||
if (body.captureType === "angle" && !angleCanon) {
|
||||
throw new BadRequestException(
|
||||
`Invalid angle captureKey "${body.captureKey}". Use one of: front, back, left, right.`,
|
||||
);
|
||||
}
|
||||
const treatAsAngle =
|
||||
!isResendCapture &&
|
||||
(body.captureType === "angle" ||
|
||||
(body.captureType === "part" && angleCanon !== null));
|
||||
|
||||
const fileUrl = buildFileLink(file.path);
|
||||
const captureData = {
|
||||
path: file.path,
|
||||
@@ -4502,7 +4782,7 @@ export class ClaimRequestManagementService {
|
||||
const updateData: any = {
|
||||
$push: {
|
||||
history: {
|
||||
type: body.captureType === 'angle' ? 'ANGLE_CAPTURED' : 'PART_CAPTURED',
|
||||
type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(currentUserId),
|
||||
actorName: claimCase.owner?.fullName || 'User',
|
||||
@@ -4512,19 +4792,51 @@ export class ClaimRequestManagementService {
|
||||
metadata: {
|
||||
captureType: body.captureType,
|
||||
captureKey: body.captureKey,
|
||||
...(treatAsAngle && angleCanon
|
||||
? { resolvedCarAngleKey: angleCanon }
|
||||
: {}),
|
||||
fileName: file.filename,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (body.captureType === 'angle') {
|
||||
updateData[`media.carAngles.${body.captureKey}`] = captureData;
|
||||
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
|
||||
if (treatAsAngle && angleCanon) {
|
||||
updateData[`media.carAngles.${angleCanon}`] = captureData;
|
||||
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
|
||||
angleCanon,
|
||||
body.captureKey,
|
||||
selectedBefore,
|
||||
);
|
||||
if (unsetFieldKeys.length) {
|
||||
updateData.$unset = {};
|
||||
for (const k of unsetFieldKeys) {
|
||||
updateData.$unset[`media.damagedParts.${k}`] = "";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateData[`media.damagedParts.${body.captureKey}`] = captureData;
|
||||
const canonicalPartKey = resolveCanonicalDamagedPartStorageKey(
|
||||
body.captureKey,
|
||||
selectedBefore,
|
||||
);
|
||||
updateData[`media.damagedParts.${canonicalPartKey}`] = captureData;
|
||||
const raw = String(body.captureKey ?? "").trim();
|
||||
const titleLegacy =
|
||||
legacyDamagedPartTitleEnFromPartKey(canonicalPartKey);
|
||||
const unsetPartKeys = new Set<string>();
|
||||
if (raw && raw !== canonicalPartKey) unsetPartKeys.add(raw);
|
||||
if (titleLegacy && titleLegacy !== canonicalPartKey) {
|
||||
unsetPartKeys.add(titleLegacy);
|
||||
}
|
||||
if (unsetPartKeys.size) {
|
||||
updateData.$unset = updateData.$unset || {};
|
||||
for (const k of unsetPartKeys) {
|
||||
updateData.$unset[`media.damagedParts.${k}`] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
|
||||
if (
|
||||
isResendCapture &&
|
||||
body.captureType === "part" &&
|
||||
@@ -4570,8 +4882,16 @@ export class ClaimRequestManagementService {
|
||||
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
|
||||
const selectedParts = updatedClaim?.damage?.selectedParts || [];
|
||||
|
||||
const anglesCaptured = anglesKeys.filter(k => hasCapture(carAnglesData, k)).length;
|
||||
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
|
||||
const anglesCaptured = anglesKeys.filter((k) =>
|
||||
hasClaimCarAngleCapture(
|
||||
carAnglesData,
|
||||
damagedPartsData,
|
||||
k as ClaimCarAngleKey,
|
||||
),
|
||||
).length;
|
||||
const partsCaptured = selectedParts.filter((p) =>
|
||||
hasDamagedPartCapture(damagedPartsData, p),
|
||||
).length;
|
||||
|
||||
const allCapturesComplete =
|
||||
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
|
||||
@@ -4906,6 +5226,182 @@ export class ClaimRequestManagementService {
|
||||
return userRating;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Claim owner signs final priced expert reply (single-party), same intent as blame
|
||||
* `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and
|
||||
* workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION).
|
||||
*/
|
||||
async submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId: string,
|
||||
agree: boolean,
|
||||
branchId: string,
|
||||
signFile: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string; fullName?: string },
|
||||
): Promise<{
|
||||
message: string;
|
||||
claimRequestId: string;
|
||||
status: ClaimCaseStatus;
|
||||
claimStatus: ClaimStatus;
|
||||
currentStep?: ClaimWorkflowStep;
|
||||
accepted: boolean;
|
||||
}> {
|
||||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||
throw new BadRequestException("Invalid claim request id");
|
||||
}
|
||||
if (!signFile) {
|
||||
throw new BadRequestException("A signature file is required");
|
||||
}
|
||||
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claimCase) {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException(
|
||||
"Only the claim owner (damaged party) can sign at this step.",
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
|
||||
throw new BadRequestException(
|
||||
`Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`,
|
||||
);
|
||||
}
|
||||
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
|
||||
throw new BadRequestException(
|
||||
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
|
||||
);
|
||||
}
|
||||
if (claimCase.claimStatus !== ClaimStatus.APPROVED) {
|
||||
throw new BadRequestException(
|
||||
`Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||
throw new ConflictException("You have already submitted a signature for this claim.");
|
||||
}
|
||||
|
||||
const activeReply =
|
||||
claimCase.evaluation?.damageExpertReplyFinal ||
|
||||
claimCase.evaluation?.damageExpertReply;
|
||||
if (!activeReply?.submittedAt) {
|
||||
throw new BadRequestException("No expert reply is available to sign off on yet.");
|
||||
}
|
||||
|
||||
const rawBranchId = (branchId ?? "").trim();
|
||||
if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) {
|
||||
throw new BadRequestException(
|
||||
"branchId is required and must be a valid MongoDB ObjectId.",
|
||||
);
|
||||
}
|
||||
const branchDoc = await this.branchDbService.findById(rawBranchId);
|
||||
if (!branchDoc) {
|
||||
throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`);
|
||||
}
|
||||
const ownerClientId = claimCase.owner?.clientId;
|
||||
if (!ownerClientId || String(branchDoc.clientKey) !== String(ownerClientId)) {
|
||||
throw new ForbiddenException(
|
||||
"This branch does not belong to the insurer for this claim.",
|
||||
);
|
||||
}
|
||||
const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply(
|
||||
activeReply as { parts?: unknown[] },
|
||||
);
|
||||
if (
|
||||
branchIdsOnPricing.size > 0 &&
|
||||
!branchIdsOnPricing.has(String(rawBranchId))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"branchId must match a branch used in the expert pricing for this claim.",
|
||||
);
|
||||
}
|
||||
|
||||
const signDoc = await this.claimSignDbService.create({
|
||||
fileName: signFile.filename,
|
||||
userId: effectiveUserId,
|
||||
path: signFile.path,
|
||||
requestId: new Types.ObjectId(claimRequestId),
|
||||
} as any);
|
||||
const signId = new Types.ObjectId(String((signDoc as any)._id));
|
||||
const signedAt = new Date();
|
||||
const approvalPayload = {
|
||||
agree,
|
||||
branchId: new Types.ObjectId(rawBranchId),
|
||||
signDetailId: signId,
|
||||
signedAt,
|
||||
};
|
||||
|
||||
const historyActor = {
|
||||
actorId: new Types.ObjectId(effectiveUserId),
|
||||
actorName: claimCase.owner?.fullName || "User",
|
||||
actorType: "user",
|
||||
};
|
||||
|
||||
if (!agree) {
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.REJECTED,
|
||||
claimStatus: ClaimStatus.REJECTED,
|
||||
"evaluation.ownerInsurerApproval": approvalPayload,
|
||||
"workflow.nextStep": null,
|
||||
},
|
||||
$push: {
|
||||
history: {
|
||||
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
|
||||
actor: historyActor,
|
||||
timestamp: signedAt,
|
||||
metadata: { branchId: rawBranchId },
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
message:
|
||||
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
|
||||
claimRequestId,
|
||||
status: ClaimCaseStatus.REJECTED,
|
||||
claimStatus: ClaimStatus.REJECTED,
|
||||
currentStep: claimCase.workflow?.currentStep,
|
||||
accepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
$set: {
|
||||
status: ClaimCaseStatus.COMPLETED,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
"evaluation.ownerInsurerApproval": approvalPayload,
|
||||
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
|
||||
history: {
|
||||
type: "OWNER_SIGNED_INSURER_APPROVAL",
|
||||
actor: historyActor,
|
||||
timestamp: signedAt,
|
||||
metadata: { branchId: rawBranchId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Your signature has been recorded. The claim is completed.",
|
||||
claimRequestId,
|
||||
status: ClaimCaseStatus.COMPLETED,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||
accepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||||
*/
|
||||
@@ -5000,17 +5496,44 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
|
||||
const carAnglesData = claim.media?.carAngles as any;
|
||||
const damagedPartsForAngles = claim.media?.damagedParts as any;
|
||||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||||
for (const k of ['front', 'back', 'left', 'right']) {
|
||||
const cap = hasCapture(carAnglesData, k);
|
||||
const cap = getClaimCarAngleCaptureBlob(
|
||||
carAnglesData,
|
||||
damagedPartsForAngles,
|
||||
k as ClaimCarAngleKey,
|
||||
) as { url?: string; path?: string } | undefined;
|
||||
carAngles[k] = {
|
||||
captured: !!cap,
|
||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const getPartLabelFa = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
hood: 'کاپوت',
|
||||
trunk: 'صندوق عقب',
|
||||
roof: 'سقف',
|
||||
front_right_door: 'درب جلو راست',
|
||||
front_left_door: 'درب جلو چپ',
|
||||
rear_right_door: 'درب عقب راست',
|
||||
rear_left_door: 'درب عقب چپ',
|
||||
front_bumper: 'سپر جلو',
|
||||
rear_bumper: 'سپر عقب',
|
||||
front_right_fender: 'گلگیر جلو راست',
|
||||
front_left_fender: 'گلگیر جلو چپ',
|
||||
rear_right_fender: 'گلگیر عقب راست',
|
||||
rear_left_fender: 'گلگیر عقب چپ',
|
||||
};
|
||||
return labels[key] || key;
|
||||
};
|
||||
|
||||
const damagedPartsData = claim.media?.damagedParts as any;
|
||||
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
|
||||
const damagedParts: Record<
|
||||
string,
|
||||
{ label_fa: string; captured: boolean; url?: string }
|
||||
> = {};
|
||||
const resendPartKeys = normalizeResendPartKeys(
|
||||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||||
);
|
||||
@@ -5019,8 +5542,11 @@ export class ClaimRequestManagementService {
|
||||
...resendPartKeys,
|
||||
]);
|
||||
for (const p of partKeysForDetails) {
|
||||
const cap = hasCapture(damagedPartsData, p);
|
||||
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as
|
||||
| { url?: string; path?: string }
|
||||
| undefined;
|
||||
damagedParts[p] = {
|
||||
label_fa: getPartLabelFa(p),
|
||||
captured: !!cap,
|
||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||||
};
|
||||
@@ -5045,6 +5571,10 @@ export class ClaimRequestManagementService {
|
||||
const ownerData = claim.owner
|
||||
? {
|
||||
userId: claim.owner.userId?.toString(),
|
||||
clientId: claim.owner.clientId?.toString(),
|
||||
...(claim.owner.userClientKey
|
||||
? { userClientKey: claim.owner.userClientKey.toString() }
|
||||
: {}),
|
||||
fullName: claim.owner.fullName,
|
||||
}
|
||||
: undefined;
|
||||
@@ -5061,6 +5591,8 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation);
|
||||
|
||||
return {
|
||||
claimRequestId: claim._id.toString(),
|
||||
publicId: claim.publicId,
|
||||
@@ -5071,7 +5603,7 @@ export class ClaimRequestManagementService {
|
||||
nextStep: claim.workflow?.nextStep,
|
||||
blameRequestId: claim.blameRequestId?.toString(),
|
||||
blameRequestNo: claim.blameRequestNo,
|
||||
...(isExpertViewer ? { owner: ownerData } : {}),
|
||||
...(ownerData ? { owner: ownerData } : {}),
|
||||
vehicle: claim.vehicle,
|
||||
selectedParts: claim.damage?.selectedParts,
|
||||
otherParts: claim.damage?.otherParts,
|
||||
@@ -5080,6 +5612,12 @@ export class ClaimRequestManagementService {
|
||||
carAngles,
|
||||
damagedParts,
|
||||
expertResend,
|
||||
evaluation: mappedEvaluation
|
||||
? {
|
||||
damageExpertReply: mappedEvaluation.damageExpertReply,
|
||||
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
||||
}
|
||||
: undefined,
|
||||
...(isExpertViewer
|
||||
? {
|
||||
userRating: claim.userRating
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
Controller,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
BadRequestException,
|
||||
Param,
|
||||
Query,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
@@ -12,17 +14,23 @@ import {
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { Types } from "mongoose";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
OuterPartCatalogItemDto,
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||||
@@ -35,6 +43,7 @@ import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
@ApiTags("claim-request-management (v2)")
|
||||
@Controller("v2/claim-request-management")
|
||||
@@ -76,7 +85,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get Claim Details (V2)",
|
||||
description:
|
||||
"Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.",
|
||||
"Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -223,6 +232,91 @@ export class ClaimRequestManagementV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW).
|
||||
*/
|
||||
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "Claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Sign final claim pricing (owner)",
|
||||
description:
|
||||
"Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " +
|
||||
"Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " +
|
||||
"`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " +
|
||||
"If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
|
||||
})
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: {
|
||||
type: "boolean",
|
||||
description: "true to accept expert pricing and complete the claim",
|
||||
},
|
||||
branchId: {
|
||||
type: "string",
|
||||
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
||||
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Already signed" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerInsurerApprovalSignV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||
throw new BadRequestException("Invalid claim request id");
|
||||
}
|
||||
const agreed =
|
||||
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({
|
||||
name: "blameRequestId",
|
||||
@@ -250,6 +344,66 @@ export class ClaimRequestManagementV2Controller {
|
||||
/**
|
||||
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
||||
*/
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(
|
||||
@Query("carType") carType?: ClaimVehicleTypeV2,
|
||||
): Promise<OuterPartCatalogItemDto[]> {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiOperation({
|
||||
summary: "Get insurer branches (V2)",
|
||||
description:
|
||||
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "insuranceId",
|
||||
description: "Insurer client id (MongoDB ObjectId)",
|
||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of branches for insurer",
|
||||
})
|
||||
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("car-other-part")
|
||||
@ApiOperation({
|
||||
summary: "Get other (non-body) parts catalog",
|
||||
description:
|
||||
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Other parts catalog",
|
||||
})
|
||||
async getCarOtherPartsV2() {
|
||||
const raw = await readFile(
|
||||
`${process.cwd()}/src/static/car-part.json`,
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
@@ -284,41 +438,35 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description: "Array of selected damaged outer parts",
|
||||
description:
|
||||
"Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Minor front damage",
|
||||
summary: "Sedan - minor front damage",
|
||||
value: {
|
||||
selectedParts: ["hood", "front_bumper", "front_right_fender"],
|
||||
carType: "sedan",
|
||||
selectedPartIds: [19, 21, 16],
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "Side impact damage",
|
||||
summary: "SUV - left side impact",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"front_left_door",
|
||||
"rear_left_door",
|
||||
"front_left_fender",
|
||||
"rear_left_fender",
|
||||
],
|
||||
carType: "suv",
|
||||
selectedPartIds: [102, 103, 104, 107],
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Rear-end collision",
|
||||
summary: "Hatchback - rear-end collision",
|
||||
value: {
|
||||
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
|
||||
carType: "hatchback",
|
||||
selectedPartIds: [225, 226, 210],
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Multiple damage areas",
|
||||
summary: "Pickup - two sides + roof",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"hood",
|
||||
"front_bumper",
|
||||
"front_right_door",
|
||||
"rear_bumper",
|
||||
"trunk",
|
||||
],
|
||||
carType: "pickup",
|
||||
selectedPartIds: [319, 312, 330],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for required document item
|
||||
@@ -93,6 +94,12 @@ export class DamagedPartItem {
|
||||
example: false,
|
||||
})
|
||||
captured: boolean;
|
||||
|
||||
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
|
||||
@ApiPropertyOptional({ example: 12 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,9 +43,13 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Blame request number' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner info' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
|
||||
})
|
||||
owner?: {
|
||||
userId: string;
|
||||
clientId?: string;
|
||||
userClientKey?: string;
|
||||
fullName?: string;
|
||||
};
|
||||
|
||||
@@ -76,7 +80,7 @@ export class ClaimDetailsV2ResponseDto {
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
@@ -84,6 +88,15 @@ export class ClaimDetailsV2ResponseDto {
|
||||
})
|
||||
expertResend?: ExpertResendDetailsV2Dto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Damage expert opinion(s): initial and final (after objection).",
|
||||
type: Object,
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||
userRating?: {
|
||||
progressSpeed: number;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartSideV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
|
||||
/**
|
||||
* Enum for valid outer car parts that can be damaged
|
||||
@@ -43,7 +47,7 @@ export class SelectOuterPartsV2Dto {
|
||||
minItems: 1,
|
||||
maxItems: 13,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedParts must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||
@@ -51,7 +55,29 @@ export class SelectOuterPartsV2Dto {
|
||||
each: true,
|
||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
||||
})
|
||||
selectedParts: OuterCarPart[];
|
||||
selectedParts?: OuterCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected outer part IDs from catalog',
|
||||
example: [9, 10, 4],
|
||||
type: [Number],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedPartIds must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
|
||||
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
|
||||
selectedPartIds?: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Vehicle type for validating available outer parts',
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: true,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +102,13 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
})
|
||||
selectedParts: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected part IDs',
|
||||
example: [9, 7, 11],
|
||||
type: [Number],
|
||||
})
|
||||
selectedPartIds: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'SELECT_OUTER_PARTS',
|
||||
@@ -94,3 +127,30 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SetClaimVehicleTypeV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Vehicle type selected by user",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
export class OuterPartCatalogItemDto {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
|
||||
@ApiProperty()
|
||||
titleFa: string;
|
||||
|
||||
@ApiProperty({ enum: OuterPartSideV2 })
|
||||
side: OuterPartSideV2;
|
||||
|
||||
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false })
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class SelectedOuterPartV2 {
|
||||
@Prop({ type: Number })
|
||||
id: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
key: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side: string;
|
||||
}
|
||||
export const SelectedOuterPartV2Schema =
|
||||
SchemaFactory.createForClass(SelectedOuterPartV2);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimDamageSelection {
|
||||
/**
|
||||
@@ -10,6 +24,10 @@ export class ClaimDamageSelection {
|
||||
@Prop({ type: [String], default: [] })
|
||||
selectedParts?: string[];
|
||||
|
||||
/** Structured selected outer parts with id + side for better downstream handling. */
|
||||
@Prop({ type: [SelectedOuterPartV2Schema], default: [] })
|
||||
selectedOuterParts?: SelectedOuterPartV2[];
|
||||
|
||||
/**
|
||||
* V2: Array of selected other (non-body) damaged parts
|
||||
* Examples: ['engine', 'suspension', 'headlight']
|
||||
|
||||
@@ -76,6 +76,25 @@ export class ClaimUserComment {
|
||||
export const ClaimUserCommentSchema =
|
||||
SchemaFactory.createForClass(ClaimUserComment);
|
||||
|
||||
/** Owner acceptance + signature after expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimOwnerInsurerApproval {
|
||||
@Prop({ type: Boolean, required: true })
|
||||
agree: boolean;
|
||||
|
||||
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
|
||||
@Prop({ type: Types.ObjectId })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
signDetailId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
signedAt?: Date;
|
||||
}
|
||||
export const ClaimOwnerInsurerApprovalSchema =
|
||||
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimExpertReply {
|
||||
@Prop({ type: String })
|
||||
@@ -205,6 +224,9 @@ export class ClaimFileRating {
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
guiltyVehicleIdentification?: number;
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
botRating?: number;
|
||||
}
|
||||
export const ClaimFileRatingSchema =
|
||||
SchemaFactory.createForClass(ClaimFileRating);
|
||||
@@ -251,6 +273,9 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimFileRatingSchema })
|
||||
rating?: ClaimFileRating;
|
||||
|
||||
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||
|
||||
@Prop({ type: String })
|
||||
visitLocation?: string;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
@@ -23,6 +24,22 @@ export class ClaimActorLock {
|
||||
}
|
||||
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
|
||||
|
||||
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimPreLockQueueSnapshot {
|
||||
@Prop({ type: String, enum: ClaimStatus, required: true })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
|
||||
currentStep: ClaimWorkflowStep;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
|
||||
nextStep?: ClaimWorkflowStep;
|
||||
}
|
||||
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
|
||||
ClaimPreLockQueueSnapshot,
|
||||
);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimWorkflow {
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep })
|
||||
@@ -40,8 +57,15 @@ export class ClaimWorkflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@Prop({ type: ClaimActorLockSchema })
|
||||
lockedBy?: ClaimActorLock;
|
||||
|
||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||
}
|
||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||
|
||||
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClientService } from "./client.service";
|
||||
import { ClientDto } from "./dto/create-client.dto";
|
||||
|
||||
@Controller("client")
|
||||
@ApiTags("client-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiBearerAuth()
|
||||
async addClient(@Body() client: ClientDto) {
|
||||
return await this.clientService.addClient(client);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard)
|
||||
async getClient(@CurrentUser() user) {
|
||||
return await this.clientService.getClients();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
class ClientName {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
persian: string;
|
||||
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
english: string;
|
||||
}
|
||||
class Property {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
smsApiKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClientDto {
|
||||
@ApiProperty({ required: true })
|
||||
@ValidateNested()
|
||||
@Type(() => ClientName)
|
||||
clientName: ClientName;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
@IsNumber()
|
||||
clientCode: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
property: Property;
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => Property)
|
||||
property?: Property;
|
||||
|
||||
@ApiProperty({ examples: ["legal", "genuine"] })
|
||||
@IsIn(["legal", "genuine"])
|
||||
useExpertMode: "legal" | "genuine";
|
||||
}
|
||||
export class ClientDtoRs {
|
||||
@@ -35,6 +48,9 @@ export class ClientDtoRs {
|
||||
useExpertsMode: string;
|
||||
constructor(readonly client) {
|
||||
this.persian = client.clientName.persian;
|
||||
this.english = client.clientName.english;
|
||||
this.clientId = client._id;
|
||||
this.useExpertsMode = client.useExpertMode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export class BranchDbService {
|
||||
async findAll(insuranceId: string): Promise<BranchModel[]> {
|
||||
return await this.branchModel.find({
|
||||
clientKey: new Types.ObjectId(insuranceId),
|
||||
});
|
||||
}, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BranchModel | null> {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
|
||||
export const CustomHeader = createParamDecorator(
|
||||
(data, ctx: ExecutionContext) => {
|
||||
console.log(ctx.switchToHttp().getRequest());
|
||||
console.log(ctx.getType());
|
||||
},
|
||||
);
|
||||
@@ -117,6 +117,7 @@ export class AllRequestDtoV2 {
|
||||
type: string;
|
||||
blameStatus: string;
|
||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||
}
|
||||
|
||||
export class AllRequestDtoRsV2 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ApiParam,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response, Request } from "express";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
@@ -45,30 +46,35 @@ export class ExpertBlameController {
|
||||
|
||||
// TODO role guard for expert fix
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertBlameService.findAll(actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.findOne(id, actor.sub);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.lockRequest(id, actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/accident-fields")
|
||||
async getAccidentFields() {
|
||||
return await this.expertBlameService.getAccidentField();
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -95,6 +101,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/first/:id")
|
||||
@ApiBody({ type: ResendFirstPartyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -107,6 +114,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/second/:id")
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiBody({ type: ResendSecondPartyDto })
|
||||
@@ -120,6 +128,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
async streamVideo(@Param("requestId") requestId: string) {
|
||||
@@ -127,6 +136,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@UseInterceptors(LoggingInterceptor)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("voice/:requestId/:voiceId")
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@@ -149,6 +159,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
||||
|
||||
@@ -34,7 +34,10 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
||||
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
|
||||
import {
|
||||
buildResendItemsWithUi,
|
||||
normalizeResendRequestedItemsList,
|
||||
} from "src/Types&Enums/blame-request-management/resend-item-ui";
|
||||
import { buildFileLink } from "src/helpers/urlCreator";
|
||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||
import { ResendRequestDto } from "./dto/resend.dto";
|
||||
@@ -46,6 +49,12 @@ import { RequestManagementService } from "src/request-management/request-managem
|
||||
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
|
||||
interface CheckedRequestEntry {
|
||||
CheckedRequest?: {
|
||||
@@ -87,6 +96,7 @@ export class ExpertBlameService {
|
||||
private readonly userSignDbService: UserSignDbService,
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
) { }
|
||||
|
||||
/** Load immutable profile fields from `expert` for the acting field expert. */
|
||||
@@ -304,15 +314,7 @@ export class ExpertBlameService {
|
||||
for (const doc of visibleCases) {
|
||||
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||
if (!w?.locked) continue;
|
||||
const la = w.lockedAt;
|
||||
if (!la) {
|
||||
staleIds.add(String(doc._id));
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
Date.now() >=
|
||||
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
|
||||
) {
|
||||
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
|
||||
staleIds.add(String(doc._id));
|
||||
}
|
||||
}
|
||||
@@ -325,6 +327,7 @@ export class ExpertBlameService {
|
||||
if (w) {
|
||||
w.locked = false;
|
||||
delete w.lockedAt;
|
||||
delete w.expiredAt;
|
||||
delete w.lockedBy;
|
||||
}
|
||||
}
|
||||
@@ -352,6 +355,15 @@ export class ExpertBlameService {
|
||||
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
||||
const parties = (doc.parties ?? []) as Array<{
|
||||
role?: string;
|
||||
vehicle?: {
|
||||
name?: string;
|
||||
inquiry?: {
|
||||
mapped?: {
|
||||
MapTypNam?: string;
|
||||
CarName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
statement?: {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
@@ -381,9 +393,43 @@ export class ExpertBlameService {
|
||||
firstParty: statementToFormKey(firstParty?.statement) ?? "",
|
||||
secondParty: statementToFormKey(secondParty?.statement) ?? "",
|
||||
},
|
||||
partiesVehicles: {
|
||||
firstPartyVehicle:
|
||||
firstParty?.vehicle?.name ||
|
||||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
|
||||
firstParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||
"",
|
||||
secondPartyVehicle:
|
||||
secondParty?.vehicle?.name ||
|
||||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
|
||||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||
"",
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
/** True while another expert holds an active workflow lock (uses `expiredAt` when set). */
|
||||
private isBlameV2WorkflowLockCurrentlyEnforced(doc: {
|
||||
workflow?: {
|
||||
locked?: boolean;
|
||||
lockedAt?: Date;
|
||||
expiredAt?: Date;
|
||||
lockedBy?: { actorId?: unknown };
|
||||
};
|
||||
}): boolean {
|
||||
if (!doc?.workflow?.locked) return false;
|
||||
const exp = doc.workflow.expiredAt;
|
||||
if (exp) {
|
||||
return Date.now() < new Date(exp as Date).getTime();
|
||||
}
|
||||
const la = doc.workflow.lockedAt;
|
||||
if (!la) return true;
|
||||
return (
|
||||
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
||||
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
||||
@@ -397,6 +443,13 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
const lockedAt = request.workflow.lockedAt;
|
||||
const expiredAt = request.workflow.expiredAt;
|
||||
if (
|
||||
expiredAt &&
|
||||
Date.now() < new Date(expiredAt as Date).getTime()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lockedAt &&
|
||||
Date.now() <
|
||||
@@ -410,7 +463,9 @@ export class ExpertBlameService {
|
||||
_id: new Types.ObjectId(requestId),
|
||||
"workflow.locked": true,
|
||||
};
|
||||
if (lockedAt) {
|
||||
if (expiredAt) {
|
||||
filter["workflow.expiredAt"] = expiredAt;
|
||||
} else if (lockedAt) {
|
||||
filter["workflow.lockedAt"] = lockedAt;
|
||||
} else if (lockedById) {
|
||||
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
||||
@@ -422,7 +477,11 @@ export class ExpertBlameService {
|
||||
filter as any,
|
||||
{
|
||||
$set: { "workflow.locked": false },
|
||||
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
"workflow.expiredAt": "",
|
||||
"workflow.lockedBy": "",
|
||||
},
|
||||
},
|
||||
{ new: false },
|
||||
);
|
||||
@@ -432,21 +491,22 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
if (!request.expert?.decision && lockedById) {
|
||||
const rid = new Types.ObjectId(requestId).toString();
|
||||
try {
|
||||
await this.expertDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(String(lockedById)) },
|
||||
{
|
||||
$inc: { "requestStats.totalChecked": -1 },
|
||||
$pull: { countedRequests: rid } as any,
|
||||
},
|
||||
);
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(lockedById),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:unchecked:expired:${lockedById}`,
|
||||
});
|
||||
this.logger.warn(
|
||||
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
|
||||
`Blame case ${requestId} workflow lock expired; unlocked in DB and emitted unchecked activity for expert ${lockedById}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`Expert stat rollback failed after blame lock expiry ${requestId}`,
|
||||
`Expert activity emit failed after blame lock expiry ${requestId}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
@@ -471,13 +531,15 @@ export class ExpertBlameService {
|
||||
if (shouldDecrementChecked) {
|
||||
updateExp.blameStatus = ReqBlameStatus.UnChecked;
|
||||
|
||||
await this.expertDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(r.actorLocked.actorId) },
|
||||
{
|
||||
$inc: { "requestStats.totalChecked": -1 },
|
||||
$pull: { countedRequests: r._id.toString() },
|
||||
},
|
||||
);
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(r.actorLocked.actorId),
|
||||
requestId: String(r._id),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(r.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(r.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${String(r._id)}:unchecked:unlock:${String(r.actorLocked.actorId)}`,
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
|
||||
@@ -527,13 +589,15 @@ export class ExpertBlameService {
|
||||
if (shouldRollbackStats) {
|
||||
update.blameStatus = ReqBlameStatus.UnChecked;
|
||||
|
||||
await this.expertDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(current.actorLocked.actorId) },
|
||||
{
|
||||
$inc: { "requestStats.totalChecked": -1 },
|
||||
$pull: { countedRequests: current._id.toString() },
|
||||
},
|
||||
);
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(current.actorLocked.actorId),
|
||||
requestId: String(current._id),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
|
||||
@@ -688,6 +752,7 @@ export class ExpertBlameService {
|
||||
try {
|
||||
requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException("Request not found");
|
||||
@@ -709,6 +774,16 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
|
||||
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined;
|
||||
const lockerId = String(w?.lockedBy?.actorId ?? "");
|
||||
if (lockerId && lockerId !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
"This request is locked by another expert.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const decision = (doc.expert as any)?.decision;
|
||||
const decidedByExpertId = decision?.decidedByExpertId;
|
||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||
@@ -851,8 +926,16 @@ export class ExpertBlameService {
|
||||
throw new BadRequestException("Request already locked or invalid status");
|
||||
}
|
||||
|
||||
// Update expert stats atomically (use findOneAndUpdate with conditions)
|
||||
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
|
||||
// Record expert check activity
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorDetail.sub),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
tenantId:
|
||||
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
|
||||
});
|
||||
|
||||
this.scheduleUnlock(updateResult);
|
||||
return { _id: requestId, lock: true };
|
||||
@@ -897,29 +980,22 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if locked and not expired
|
||||
if (request.workflow?.locked) {
|
||||
const lockedAt = request.workflow.lockedAt;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
if (Date.now() < lockExpiryTime) {
|
||||
// Lock is still valid
|
||||
const lockedByActorId = String(
|
||||
request.workflow.lockedBy?.actorId ?? "",
|
||||
);
|
||||
if (lockedByActorId === actorDetail.sub) {
|
||||
throw new BadRequestException(
|
||||
"You have already locked this request",
|
||||
);
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
"Request is currently locked by another expert",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Lock expired, allow re-locking (continue below)
|
||||
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
|
||||
if (
|
||||
request.workflow?.locked &&
|
||||
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
|
||||
) {
|
||||
const lockedByActorId = String(
|
||||
request.workflow.lockedBy?.actorId ?? "",
|
||||
);
|
||||
if (lockedByActorId === actorDetail.sub) {
|
||||
throw new BadRequestException(
|
||||
"You have already locked this request",
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
"Request is currently locked by another expert",
|
||||
);
|
||||
}
|
||||
|
||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||
@@ -931,6 +1007,9 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
"workflow.locked": true,
|
||||
"workflow.lockedAt": now,
|
||||
"workflow.expiredAt": new Date(
|
||||
now.getTime() + this.blameV2LockTtlMs,
|
||||
),
|
||||
"workflow.lockedBy": {
|
||||
actorId: new Types.ObjectId(actorDetail.sub),
|
||||
actorName: actorDetail.fullName || "Unknown Expert",
|
||||
@@ -945,8 +1024,15 @@ export class ExpertBlameService {
|
||||
throw new InternalServerErrorException("Failed to lock the request");
|
||||
}
|
||||
|
||||
// Update expert stats (reusing existing helper)
|
||||
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorDetail.sub),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
|
||||
});
|
||||
|
||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||
const phones = (request.parties || [])
|
||||
@@ -958,6 +1044,7 @@ export class ExpertBlameService {
|
||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||
{
|
||||
receptor: phone,
|
||||
fileKind: "blame",
|
||||
publicId: request.publicId,
|
||||
expertLastName,
|
||||
},
|
||||
@@ -1064,7 +1151,15 @@ export class ExpertBlameService {
|
||||
`partyId ${uid} is not a party userId on this blame request.`,
|
||||
);
|
||||
}
|
||||
for (const item of party.requestedItems || []) {
|
||||
const normalizedItems = normalizeResendRequestedItemsList(
|
||||
party.requestedItems as string[],
|
||||
);
|
||||
if (normalizedItems.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Party ${uid}: requestedItems must include at least one valid resend item (e.g. drivingLicense, voice, description).`,
|
||||
);
|
||||
}
|
||||
for (const item of normalizedItems) {
|
||||
if (!allowedItems.has(String(item))) {
|
||||
throw new BadRequestException(
|
||||
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
|
||||
@@ -1078,9 +1173,12 @@ export class ExpertBlameService {
|
||||
|
||||
const partyResendRequests = resendDto.parties.map((party) => {
|
||||
const uid = String(party.partyId);
|
||||
const normalizedItems = normalizeResendRequestedItemsList(
|
||||
party.requestedItems as string[],
|
||||
);
|
||||
return {
|
||||
partyId: new Types.ObjectId(uid),
|
||||
requestedItems: party.requestedItems,
|
||||
requestedItems: normalizedItems,
|
||||
description: party.description || "",
|
||||
requestedAt: now,
|
||||
completed: false,
|
||||
@@ -1103,6 +1201,7 @@ export class ExpertBlameService {
|
||||
},
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
"workflow.expiredAt": "",
|
||||
"workflow.lockedBy": "",
|
||||
},
|
||||
};
|
||||
@@ -1118,11 +1217,63 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
|
||||
});
|
||||
|
||||
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||
requestId,
|
||||
);
|
||||
|
||||
// TODO: Send notifications to parties (SMS/Push) about required documents
|
||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||
const requestIdToken = String(request._id);
|
||||
const partyPhoneByUserId = new Map<string, string>();
|
||||
for (const p of request.parties || []) {
|
||||
const uid = p?.person?.userId ? String(p.person.userId) : "";
|
||||
const phone = p?.person?.phoneNumber;
|
||||
if (uid && typeof phone === "string" && phone.length > 0) {
|
||||
partyPhoneByUserId.set(uid, phone);
|
||||
}
|
||||
}
|
||||
const partyHasResendPayload = (row: {
|
||||
requestedItems?: readonly string[];
|
||||
description?: string;
|
||||
}) => {
|
||||
const n = Array.isArray(row.requestedItems)
|
||||
? row.requestedItems.length
|
||||
: 0;
|
||||
return n > 0 || String(row.description ?? "").trim().length > 0;
|
||||
};
|
||||
for (const p of partyResendRequests) {
|
||||
if (!partyHasResendPayload(p)) continue;
|
||||
const uid = String((p as any).partyId);
|
||||
const phone = partyPhoneByUserId.get(uid);
|
||||
if (!phone) continue;
|
||||
const role =
|
||||
String(
|
||||
request.parties?.find(
|
||||
(x: any) => String(x?.person?.userId) === uid,
|
||||
)?.role,
|
||||
) === "SECOND"
|
||||
? "SECOND"
|
||||
: "FIRST";
|
||||
await this.smsOrchestrationService.sendResendDocumentsNotice({
|
||||
receptor: phone,
|
||||
fileKind: "blame",
|
||||
publicId: request.publicId,
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
role,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: String(request._id),
|
||||
@@ -1262,6 +1413,7 @@ export class ExpertBlameService {
|
||||
},
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
"workflow.expiredAt": "",
|
||||
"workflow.lockedBy": "",
|
||||
},
|
||||
};
|
||||
@@ -1277,6 +1429,40 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorId),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
tenantId:
|
||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:handled:${actorId}`,
|
||||
});
|
||||
|
||||
// THIRD_PARTY: both parties must sign; send each their deep link (mutual-agreement uses yara-blame-agreement elsewhere).
|
||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||
const requestIdToken = String(request._id);
|
||||
const expertLastName =
|
||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||
const parties = request.parties || [];
|
||||
for (const partyRole of [PartyRole.FIRST, PartyRole.SECOND] as const) {
|
||||
const p = parties.find((x: any) => x?.role === partyRole);
|
||||
const phone = p?.person?.phoneNumber;
|
||||
if (!phone || typeof phone !== "string") continue;
|
||||
const linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST";
|
||||
await this.smsOrchestrationService.sendSignatureReviewNotice({
|
||||
receptor: phone,
|
||||
fileKind: "blame",
|
||||
publicId: request.publicId,
|
||||
expertLastName,
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
linkRole,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: String(request._id),
|
||||
status: CaseStatus.WAITING_FOR_SIGNATURES,
|
||||
@@ -1383,6 +1569,7 @@ export class ExpertBlameService {
|
||||
},
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
"workflow.expiredAt": "",
|
||||
"workflow.lockedBy": "",
|
||||
},
|
||||
};
|
||||
@@ -1416,65 +1603,32 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDamageExpertStats(
|
||||
expertId: string,
|
||||
requestId: string,
|
||||
type: "checked" | "handled",
|
||||
) {
|
||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
||||
console.warn("Invalid expertId, requestId, or type");
|
||||
private async recordBlameExpertActivity(args: {
|
||||
expertId: string;
|
||||
requestId: string;
|
||||
eventType: ExpertFileActivityType;
|
||||
tenantId?: string;
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const tenantId =
|
||||
args.tenantId && Types.ObjectId.isValid(args.tenantId)
|
||||
? args.tenantId
|
||||
: undefined;
|
||||
if (
|
||||
!Types.ObjectId.isValid(args.expertId) ||
|
||||
!Types.ObjectId.isValid(args.requestId) ||
|
||||
!tenantId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expert = await this.expertDbService.findOne({
|
||||
_id: new Types.ObjectId(expertId),
|
||||
await this.expertFileActivityDbService.recordEvent({
|
||||
expertId: args.expertId,
|
||||
tenantId,
|
||||
fileId: args.requestId,
|
||||
fileType: ExpertFileKind.BLAME,
|
||||
eventType: args.eventType,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
});
|
||||
|
||||
if (!expert) {
|
||||
console.warn("Expert not found:", expertId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestIdStr = new Types.ObjectId(requestId).toString();
|
||||
const countedRequestIds =
|
||||
expert.countedRequests?.map((id) => id.toString()) || [];
|
||||
|
||||
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
|
||||
console.log(
|
||||
`Request ${requestIdStr} already checked for expert ${expertId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const update: any = { $inc: {}, $push: {} };
|
||||
|
||||
if (type === "checked") {
|
||||
update.$inc["requestStats.totalChecked"] = 1;
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else if (type === "handled") {
|
||||
update.$inc["requestStats.totalHandled"] = 1;
|
||||
|
||||
if (countedRequestIds.includes(requestIdStr)) {
|
||||
update.$inc["requestStats.totalChecked"] = -1;
|
||||
}
|
||||
|
||||
if (!countedRequestIds.includes(requestIdStr)) {
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else {
|
||||
delete update.$push;
|
||||
}
|
||||
}
|
||||
|
||||
const updateResult = await this.expertDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(expertId) },
|
||||
update,
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
console.warn("Failed to update expert stats for:", expertId);
|
||||
} else {
|
||||
console.log(`Expert stats updated (${type}) for expert:`, expertId);
|
||||
}
|
||||
}
|
||||
|
||||
async replyRequest(requestId: string, reply: SubmitReplyDto, userId: string) {
|
||||
@@ -1757,11 +1911,15 @@ export class ExpertBlameService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.expertDbService.updateStats(
|
||||
actorDetail.sub,
|
||||
"handled",
|
||||
requestId,
|
||||
);
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: String(actorDetail.sub),
|
||||
requestId: String(requestId),
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
tenantId:
|
||||
String(request.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||
String(request.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||
idempotencyKey: `blame:${requestId}:handled:inperson:${actorDetail.sub}`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -98,6 +99,16 @@ export class ClaimDetailV2ResponseDto {
|
||||
damageExpertReplyFinal?: unknown;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
|
||||
})
|
||||
objection?: {
|
||||
objectionParts?: Array<Record<string, unknown>>;
|
||||
newParts?: Array<Record<string, unknown>>;
|
||||
submittedAt?: Date | string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
|
||||
|
||||
@@ -17,10 +17,12 @@ export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||
lockedBy?: {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt?: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||
|
||||
@@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description: "Extra document keys to upload (may include types not in the initial claim form).",
|
||||
description:
|
||||
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
|
||||
export class ExpertClaimController {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiQuery({
|
||||
@@ -64,12 +67,14 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("imageRequired/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
||||
return await this.expertClaimService.imageRequired(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getRequiredDocuments(
|
||||
@@ -79,11 +84,13 @@ export class ExpertClaimController {
|
||||
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitReplyRequest(
|
||||
@@ -98,6 +105,7 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("resend/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitResend(
|
||||
@@ -117,6 +125,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
@@ -134,6 +143,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("link/:id/video")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVideoLink(
|
||||
@@ -143,12 +153,14 @@ export class ExpertClaimController {
|
||||
return this.expertClaimService.getVideoLink(id, query);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/voice")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVoiceLink(@Param("id") id: string) {
|
||||
return await this.expertClaimService.getVoiceLink(id);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("validate-factors/:claimId")
|
||||
@ApiParam({ name: "claimId" })
|
||||
@ApiBody({ type: FactorValidationDto })
|
||||
@@ -166,11 +178,13 @@ export class ExpertClaimController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ExpertClaimController } from "./expert-claim.controller";
|
||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
ExpertBlameModule,
|
||||
AiModule,
|
||||
RequestManagementModule,
|
||||
SmsOrchestrationModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
export class CreateInsurerExpertDto {
|
||||
@ApiProperty({ example: "Ali" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: "Ahmadi" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: "1234567890" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nationalCode: string;
|
||||
|
||||
@ApiProperty({ example: "expert@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "StrongPass!123" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "09121234567" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mobile: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "665f0e5ab74d670939b08920",
|
||||
description: "Branch id this expert belongs to",
|
||||
})
|
||||
@IsMongoId()
|
||||
branchId: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: UserType, example: UserType.LEGAL })
|
||||
@IsOptional()
|
||||
@IsEnum(UserType)
|
||||
userType?: UserType;
|
||||
|
||||
@ApiPropertyOptional({ example: "02112345678" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "No. 12, Sample St." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export class CreateBlameExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.EXPERT],
|
||||
default: RoleEnum.EXPERT,
|
||||
})
|
||||
role?: RoleEnum.EXPERT;
|
||||
}
|
||||
|
||||
export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.DAMAGE_EXPERT],
|
||||
default: RoleEnum.DAMAGE_EXPERT,
|
||||
})
|
||||
role?: RoleEnum.DAMAGE_EXPERT;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
|
||||
@Controller("expert-insurer")
|
||||
@ApiTags("expert-insurer-panel")
|
||||
@@ -35,16 +40,59 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
export class ExpertInsurerController {
|
||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
||||
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
@Get("branches")
|
||||
async getInsuranceBranches(@CurrentUser() insurer) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("experts/blame")
|
||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||
async addBlameExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateBlameExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@Post("experts/claim")
|
||||
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
||||
async addClaimExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateClaimExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@ApiQuery({ name: "page", type: Number })
|
||||
@ApiQuery({ name: "response_count", type: Number })
|
||||
@Get("list")
|
||||
@Get("experts/list")
|
||||
async getAllExperts(
|
||||
@Query("page") page: number,
|
||||
@Query("response_count") count: number,
|
||||
@@ -57,48 +105,38 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("top-experts")
|
||||
@Get("experts/top")
|
||||
@ApiOperation({
|
||||
summary: "Top blame vs claim experts for this insurer",
|
||||
description:
|
||||
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
|
||||
})
|
||||
async getTopExperts(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
||||
}
|
||||
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Get("/:expertId")
|
||||
async requestDetail(
|
||||
@Get("files/:publicId")
|
||||
@ApiParam({ name: "publicId" })
|
||||
async getFileDetailsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Param("expertId") id: string,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
@Param("publicId") publicId: string,
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(
|
||||
id,
|
||||
role,
|
||||
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
||||
insurer.clientKey,
|
||||
publicId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
description: "Detailed expert rating",
|
||||
description:
|
||||
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -135,7 +173,7 @@ export class ExpertInsurerController {
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
example: 4,
|
||||
description: "برای امتیاز دادن به بات",
|
||||
description: "برای امتیاز دادن به عملکرد بات",
|
||||
},
|
||||
},
|
||||
required: [
|
||||
@@ -154,48 +192,70 @@ export class ExpertInsurerController {
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Put("/:requestId/rating")
|
||||
async rateExperts(
|
||||
@ApiParam({ name: "publicId" })
|
||||
@Put("files/:publicId/rating")
|
||||
async rateExpertsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Param("requestId") requestId: string,
|
||||
@Param("publicId") publicId: string,
|
||||
@Body() rating: FileRating,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
) {
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.rateExpertOnFile(
|
||||
requestId,
|
||||
return await this.expertInsurerService.rateExpertByPublicId(
|
||||
publicId,
|
||||
rating,
|
||||
role,
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getInsurerStatusReport(
|
||||
@CurrentUser() actor,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||
actor,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiOperation({
|
||||
summary: "Files handled by one roster expert (summary rows)",
|
||||
description:
|
||||
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
|
||||
})
|
||||
@Get("/:expertId")
|
||||
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
return await this.expertInsurerService.getAllFilesForInsurerExpert(
|
||||
id,
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
BranchModel,
|
||||
BranchSchema,
|
||||
} from "src/client/entities/schema/branch.schema";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClaimRequestManagementModule,
|
||||
RequestManagementModule,
|
||||
AuthModule,
|
||||
HashModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
MongooseModule.forFeature([
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Types } from "mongoose";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
CreateInsurerExpertDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
import {
|
||||
blameCaseTouchesClient,
|
||||
claimCaseTouchesClient,
|
||||
@@ -16,17 +21,25 @@ import {
|
||||
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 { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertInsurerService {
|
||||
constructor(
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly branchDbService: BranchDbService,
|
||||
private readonly hashService: HashService,
|
||||
) {}
|
||||
|
||||
private getClientId(actorOrId: any): Types.ObjectId {
|
||||
@@ -37,6 +50,53 @@ export class ExpertInsurerService {
|
||||
return new Types.ObjectId(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant scope on expert / damage-expert: `clientKey` may be stored as ObjectId or
|
||||
* string (legacy and collection-specific writes). Match both, same as reports service.
|
||||
*/
|
||||
private clientKeyScopeFilter(clientObjectId: Types.ObjectId) {
|
||||
const oid = clientObjectId;
|
||||
const str = String(oid);
|
||||
return { $or: [{ clientKey: oid }, { clientKey: str }] };
|
||||
}
|
||||
|
||||
private async buildExpertActivityStatsMap(
|
||||
tenantId: Types.ObjectId,
|
||||
expertIds: string[],
|
||||
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
|
||||
const events = await this.expertFileActivityDbService.findByTenantAndExperts(
|
||||
tenantId,
|
||||
expertIds,
|
||||
);
|
||||
const stateByExpertFile = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
|
||||
for (const e of events) {
|
||||
const key = `${String(e.expertId)}:${String(e.fileId)}`;
|
||||
const prev = stateByExpertFile.get(key) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByExpertFile.set(key, prev);
|
||||
}
|
||||
|
||||
const result: Record<string, { totalHandled: number; totalChecked: number }> = {};
|
||||
for (const expertId of expertIds) {
|
||||
result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
||||
}
|
||||
for (const [key, st] of stateByExpertFile.entries()) {
|
||||
const [expertId] = key.split(":");
|
||||
if (!result[expertId]) result[expertId] = { totalHandled: 0, totalChecked: 0 };
|
||||
if (st.handled) result[expertId].totalHandled += 1;
|
||||
else if (st.checked) result[expertId].totalChecked += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseObjectId(value: string, label: string): Types.ObjectId {
|
||||
if (!Types.ObjectId.isValid(value)) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
@@ -84,13 +144,99 @@ export class ExpertInsurerService {
|
||||
};
|
||||
}
|
||||
|
||||
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
|
||||
private blameFieldExpertIdCandidates(b: any): string[] {
|
||||
const raw: unknown[] = [
|
||||
b?.expert?.assignedExpertId,
|
||||
b?.initiatedByFieldExpertId,
|
||||
b?.expert?.decision?.decidedByExpertId,
|
||||
];
|
||||
const out = new Set<string>();
|
||||
for (const x of raw) {
|
||||
if (x != null && x !== "") out.add(String(x));
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
private claimDamageExpertActorId(c: any): string | null {
|
||||
const reply =
|
||||
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
|
||||
const aid = reply?.actorDetail?.actorId;
|
||||
if (aid == null || aid === "") return null;
|
||||
return String(aid);
|
||||
}
|
||||
|
||||
private mapBlameFileSummaryForInsurerExpert(b: any) {
|
||||
return {
|
||||
kind: "blame" as const,
|
||||
requestId: String(b._id),
|
||||
publicId: b.publicId,
|
||||
requestNo: b.requestNo,
|
||||
type: b.type,
|
||||
status: b.status,
|
||||
blameStatus: b.blameStatus,
|
||||
createdAt: b.createdAt,
|
||||
updatedAt: b.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapClaimFileSummaryForInsurerExpert(c: any) {
|
||||
return {
|
||||
kind: "claim" as const,
|
||||
requestId: String(c._id),
|
||||
publicId: c.publicId,
|
||||
requestNo: c.requestNo ?? c.requestNumber,
|
||||
status: c.status,
|
||||
claimStatus: c.claimStatus,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private buildPartiesPreview(parties: any[] | undefined) {
|
||||
if (!Array.isArray(parties) || !parties.length) return undefined;
|
||||
return parties.map((p: any) => ({
|
||||
role: p?.role,
|
||||
fullName: p?.person?.fullName,
|
||||
vehicle: {
|
||||
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
|
||||
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
|
||||
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private sanitizeVehicleInquiry(vehicle: any) {
|
||||
if (!vehicle || typeof vehicle !== "object") return vehicle;
|
||||
const inquiry = vehicle.inquiry;
|
||||
if (!inquiry || typeof inquiry !== "object") return vehicle;
|
||||
const { raw, mapped, ...safeInquiry } = inquiry;
|
||||
return {
|
||||
...vehicle,
|
||||
inquiry: safeInquiry,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizePartyForInsurerView(party: any) {
|
||||
if (!party || typeof party !== "object") return party;
|
||||
return {
|
||||
...party,
|
||||
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
? (
|
||||
[
|
||||
insurerRating.collisionMethodAccuracy,
|
||||
insurerRating.evaluationTimeliness,
|
||||
insurerRating.accidentCauseAccuracy,
|
||||
insurerRating.guiltyVehicleIdentification,
|
||||
] as unknown[]
|
||||
).filter((v): v is number => typeof v === "number" && !isNaN(v))
|
||||
: [];
|
||||
const userValues = userRating
|
||||
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
||||
@@ -115,11 +261,6 @@ export class ExpertInsurerService {
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -127,20 +268,26 @@ export class ExpertInsurerService {
|
||||
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||
const idStr = String(clientObjectId);
|
||||
return all
|
||||
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
|
||||
.filter((f) => claimCaseTouchesClient(f, idStr))
|
||||
.map((f) => this.normalizeClaimCase(f));
|
||||
}
|
||||
|
||||
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||||
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
|
||||
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
||||
this.expertDbService.findAll(ckFilter as never),
|
||||
this.damageExpertDbService.findAll(ckFilter as never),
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
]);
|
||||
|
||||
const allExpertsRaw = [...experts, ...damageExperts];
|
||||
const expertIds = allExpertsRaw.map((e) => String(e._id));
|
||||
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
|
||||
clientObjectId,
|
||||
expertIds,
|
||||
);
|
||||
const expertTotalRatingsMap: Record<string, number[]> = {};
|
||||
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
||||
|
||||
@@ -155,6 +302,7 @@ export class ExpertInsurerService {
|
||||
if (!rating || typeof rating !== "object") return;
|
||||
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
||||
for (const [category, value] of Object.entries(rating)) {
|
||||
if (category === "botRating") continue;
|
||||
if (typeof value === "number" && !isNaN(value)) {
|
||||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
||||
expertRatingsByCategoryMap[expertId][category] = [];
|
||||
@@ -171,7 +319,7 @@ export class ExpertInsurerService {
|
||||
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
||||
}
|
||||
|
||||
const allExperts = allExpertsRaw.map((expert) => {
|
||||
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
|
||||
const expertIdStr = expert._id.toString();
|
||||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
||||
const overallAverageRating = totalRatings.length
|
||||
@@ -192,13 +340,22 @@ export class ExpertInsurerService {
|
||||
_id: expert._id,
|
||||
fullName: `${expert.firstName} ${expert.lastName}`,
|
||||
role: expert.role,
|
||||
expertKind,
|
||||
type: expert.userType,
|
||||
requestStats: expert.requestStats,
|
||||
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
||||
totalHandled: 0,
|
||||
totalChecked: 0,
|
||||
},
|
||||
createdAt: expert.createdAt,
|
||||
overallAverageRating,
|
||||
averageRatingsByCategory,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const allExperts = [
|
||||
...experts.map((e) => mapExpertRow(e, "blame")),
|
||||
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
||||
];
|
||||
|
||||
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
||||
@@ -212,20 +369,33 @@ export class ExpertInsurerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns top 10 experts for the current insurer client based on
|
||||
* combined insurer + user ratings on their handled files.
|
||||
* Top experts per roster: blame rows come from the `expert` collection (files they
|
||||
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
|
||||
* Scores are aggregated from **file documents** that insurer can see: for each such
|
||||
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
|
||||
* excluding `botRating`), optionally blend with the file’s user rating, average those
|
||||
* combined scores per file, then average across that expert’s files (`overallAverageRating`).
|
||||
*/
|
||||
async getTopExpertsForClient(actor): Promise<any[]> {
|
||||
async getTopExpertsForClient(actor): Promise<{
|
||||
blameExperts: any[];
|
||||
claimExperts: any[];
|
||||
}> {
|
||||
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||||
const experts = result?.experts || [];
|
||||
const rows = result?.experts || [];
|
||||
|
||||
const sorted = [...experts].sort((a, b) => {
|
||||
const ar = a.overallAverageRating ?? 0;
|
||||
const br = b.overallAverageRating ?? 0;
|
||||
return br - ar;
|
||||
});
|
||||
const byRatingDesc = (a: any, b: any) =>
|
||||
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||||
|
||||
return sorted.slice(0, 10);
|
||||
const blameExperts = rows
|
||||
.filter((e) => e.expertKind === "blame")
|
||||
.sort(byRatingDesc)
|
||||
.slice(0, 10);
|
||||
const claimExperts = rows
|
||||
.filter((e) => e.expertKind === "claim")
|
||||
.sort(byRatingDesc)
|
||||
.slice(0, 10);
|
||||
|
||||
return { blameExperts, claimExperts };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,101 +414,124 @@ export class ExpertInsurerService {
|
||||
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
|
||||
}
|
||||
|
||||
private async assertExpertBelongsToInsurer(
|
||||
expertObjectId: Types.ObjectId,
|
||||
insurerClientKey: string,
|
||||
) {
|
||||
const ck = String(insurerClientKey);
|
||||
const clientOid = new Types.ObjectId(ck);
|
||||
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
|
||||
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||||
const clientOid = this.getClientId(insurerClientKey);
|
||||
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
||||
const [experts, damageExperts] = await Promise.all([
|
||||
this.expertDbService.findAll({ clientKey: ck }),
|
||||
this.damageExpertDbService.findAll({ clientKey: clientOid }),
|
||||
this.expertDbService.findAll(ckFilter as never),
|
||||
this.damageExpertDbService.findAll(ckFilter as never),
|
||||
]);
|
||||
const id = String(expertObjectId);
|
||||
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
|
||||
if (!ok) {
|
||||
const onBlameRoster = experts.some((e) => String(e._id) === id);
|
||||
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
||||
if (!onBlameRoster && !onClaimRoster) {
|
||||
throw new ForbiddenException(
|
||||
"This expert is not registered under your insurance company.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 clientKeyStr = String(clientOid);
|
||||
if (onBlameRoster) {
|
||||
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||
return blames
|
||||
.filter(
|
||||
(b) =>
|
||||
blameCaseTouchesClient(b, clientKeyStr) &&
|
||||
this.blameFieldExpertIdCandidates(b).includes(id),
|
||||
)
|
||||
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
|
||||
}
|
||||
if (onClaimRoster) {
|
||||
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||
return claims
|
||||
.filter(
|
||||
(c) =>
|
||||
claimCaseTouchesClient(c, clientKeyStr) &&
|
||||
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
|
||||
String(expertObjectId),
|
||||
this.claimDamageExpertActorId(c) === id,
|
||||
)
|
||||
.map((c) => this.normalizeClaimCase(c));
|
||||
.map((c) => this.mapClaimFileSummaryForInsurerExpert(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));
|
||||
return [];
|
||||
}
|
||||
|
||||
async rateExpertOnFile(
|
||||
requestId: string,
|
||||
rating: FileRating,
|
||||
role: "claim" | "blame",
|
||||
insurerClientKey: string,
|
||||
) {
|
||||
const _id = this.parseObjectId(requestId, "request id");
|
||||
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
||||
for (const [key, value] of Object.entries(rating || {})) {
|
||||
private validateInsurerFileRating(rating: FileRating) {
|
||||
const required: (keyof FileRating)[] = [
|
||||
"collisionMethodAccuracy",
|
||||
"evaluationTimeliness",
|
||||
"accidentCauseAccuracy",
|
||||
"guiltyVehicleIdentification",
|
||||
"botRating",
|
||||
];
|
||||
for (const key of required) {
|
||||
const value = rating?.[key];
|
||||
if (typeof value !== "number" || value < 0 || value > 5) {
|
||||
throw new BadRequestException(`${key} must be a number between 0 and 5`);
|
||||
}
|
||||
}
|
||||
if (role === "claim") {
|
||||
const existing = await this.claimCaseDbService.findById(_id);
|
||||
if (!existing) throw new NotFoundException("Claim file not found");
|
||||
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||
throw new ForbiddenException("This claim does not belong to your organization.");
|
||||
}
|
||||
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one insurer rating to every underlying case (claim and/or blame) for
|
||||
* the shared `publicId`, when that case exists and belongs to this insurer.
|
||||
*/
|
||||
async rateExpertByPublicId(
|
||||
publicId: string,
|
||||
rating: FileRating,
|
||||
insurerClientKey: string,
|
||||
) {
|
||||
if (!publicId?.trim()) {
|
||||
throw new BadRequestException("publicId is required");
|
||||
}
|
||||
this.validateInsurerFileRating(rating);
|
||||
|
||||
const id = this.getClientId(insurerClientKey);
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
]);
|
||||
|
||||
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
|
||||
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
|
||||
|
||||
if (!blame && !claim) {
|
||||
throw new NotFoundException("File not found for this publicId");
|
||||
}
|
||||
|
||||
const out: {
|
||||
publicId: string;
|
||||
claim?: { requestId: string; updatedRating: FileRating };
|
||||
blame?: { requestId: string; updatedRating: FileRating };
|
||||
} = { publicId };
|
||||
|
||||
if (claim) {
|
||||
const claimId = this.parseObjectId(String((claim as any)._id), "claim id");
|
||||
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
|
||||
$set: { "evaluation.rating": rating },
|
||||
});
|
||||
if (!updated) throw new NotFoundException("Claim file not found");
|
||||
return {
|
||||
message: "Claim expert rated successfully",
|
||||
updatedRating: (updated as any)?.evaluation?.rating || rating,
|
||||
requestId: updated._id,
|
||||
out.claim = {
|
||||
requestId: String((updated as any)._id),
|
||||
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
|
||||
};
|
||||
}
|
||||
if (role === "blame") {
|
||||
const existing = await this.blameRequestDbService.findById(_id);
|
||||
if (!existing) throw new NotFoundException("Blame file not found");
|
||||
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||
throw new ForbiddenException("This blame case does not belong to your organization.");
|
||||
}
|
||||
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
|
||||
|
||||
if (blame) {
|
||||
const blameId = this.parseObjectId(String((blame as any)._id), "blame id");
|
||||
const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, {
|
||||
$set: { "expert.rating": rating },
|
||||
});
|
||||
if (!updated) throw new NotFoundException("Blame file not found");
|
||||
return {
|
||||
message: "Blame expert rated successfully",
|
||||
updatedRating: (updated as any)?.expert?.rating || rating,
|
||||
requestId: updated._id,
|
||||
out.blame = {
|
||||
requestId: String((updated as any)._id),
|
||||
updatedRating: (updated as any)?.expert?.rating ?? rating,
|
||||
};
|
||||
}
|
||||
throw new BadRequestException("Invalid role");
|
||||
|
||||
return {
|
||||
message: "Rating saved for this file",
|
||||
...out,
|
||||
};
|
||||
}
|
||||
|
||||
async retrieveAllFilesOfClient(insurerId: string) {
|
||||
@@ -347,7 +540,245 @@ export class ExpertInsurerService {
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
]);
|
||||
return { blameFiles, claimFiles };
|
||||
|
||||
const byPublicId = new Map<
|
||||
string,
|
||||
{
|
||||
publicId: string;
|
||||
fileType?: string;
|
||||
parties?: Array<{
|
||||
role?: string;
|
||||
fullName?: string;
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
plate?: string;
|
||||
};
|
||||
}>;
|
||||
blame?: {
|
||||
requestId?: string;
|
||||
requestNo?: string;
|
||||
status?: string;
|
||||
parties?: Array<{
|
||||
role?: string;
|
||||
fullName?: string;
|
||||
vehicle?: {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
plate?: string;
|
||||
};
|
||||
}>;
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
};
|
||||
claim?: {
|
||||
requestId?: string;
|
||||
requestNo?: string;
|
||||
status?: string;
|
||||
claimStatus?: string;
|
||||
currentStep?: string;
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
};
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const b of blameFiles) {
|
||||
const key = String((b as any).publicId || (b as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||||
const partiesPreview = this.buildPartiesPreview((b as any).parties);
|
||||
prev.fileType = prev.fileType ?? (b as any).type;
|
||||
prev.blame = {
|
||||
requestId: (b as any)?._id?.toString?.() || String((b as any)?._id || ""),
|
||||
requestNo: (b as any).requestNo || String((b as any).requestNumber || ""),
|
||||
status: (b as any).status,
|
||||
parties: partiesPreview,
|
||||
createdAt: (b as any).createdAt,
|
||||
updatedAt: (b as any).updatedAt,
|
||||
};
|
||||
prev.parties = prev.parties ?? partiesPreview;
|
||||
prev.createdAt = prev.createdAt ?? (b as any).createdAt;
|
||||
prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt;
|
||||
byPublicId.set(key, prev);
|
||||
}
|
||||
|
||||
for (const c of claimFiles) {
|
||||
const key = String((c as any).publicId || (c as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = byPublicId.get(key) ?? { publicId: key };
|
||||
const claimPartiesPreview = this.buildPartiesPreview((c as any)?.snapshot?.parties);
|
||||
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
|
||||
prev.claim = {
|
||||
requestId: (c as any)?._id?.toString?.() || String((c as any)?._id || ""),
|
||||
requestNo: (c as any).requestNo || String((c as any).requestNumber || ""),
|
||||
status: (c as any).status,
|
||||
claimStatus: (c as any).claimStatus,
|
||||
currentStep: (c as any).currentStep,
|
||||
createdAt: (c as any).createdAt,
|
||||
updatedAt: (c as any).updatedAt,
|
||||
};
|
||||
prev.parties = prev.parties ?? claimPartiesPreview;
|
||||
prev.createdAt = prev.createdAt ?? (c as any).createdAt;
|
||||
prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt;
|
||||
byPublicId.set(key, prev);
|
||||
}
|
||||
|
||||
const files = Array.from(byPublicId.values())
|
||||
.map((f) => ({
|
||||
publicId: f.publicId,
|
||||
fileType: f.fileType,
|
||||
hasClaim: !!f.claim,
|
||||
parties: f.parties,
|
||||
blame: f.blame,
|
||||
claim: f.claim,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
return {
|
||||
total: files.length,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) {
|
||||
if (!publicId?.trim()) {
|
||||
throw new BadRequestException("publicId is required");
|
||||
}
|
||||
|
||||
const id = this.getClientId(insurerId);
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
]);
|
||||
|
||||
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
|
||||
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
|
||||
|
||||
if (!blame && !claim) {
|
||||
throw new NotFoundException("File not found for this publicId");
|
||||
}
|
||||
|
||||
const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : [];
|
||||
const firstParty = parties.find((p: any) => p?.role === "FIRST");
|
||||
const secondParty = parties.find((p: any) => p?.role === "SECOND");
|
||||
const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties)
|
||||
? (claim as any).snapshot.parties
|
||||
: undefined;
|
||||
|
||||
const overview = {
|
||||
publicId,
|
||||
requestNo:
|
||||
(blame as any)?.requestNo ||
|
||||
(claim as any)?.requestNo ||
|
||||
(blame as any)?.requestNumber ||
|
||||
(claim as any)?.requestNumber,
|
||||
type: (blame as any)?.type,
|
||||
hasClaim: !!claim,
|
||||
blameStatus: (blame as any)?.status,
|
||||
claimStatus: (claim as any)?.status,
|
||||
claimReviewStatus: (claim as any)?.claimStatus,
|
||||
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
|
||||
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
|
||||
};
|
||||
|
||||
const blameDetails = blame
|
||||
? {
|
||||
requestId:
|
||||
(blame as any)?._id?.toString?.() || String((blame as any)?._id || ""),
|
||||
requestNo:
|
||||
(blame as any).requestNo || String((blame as any).requestNumber || ""),
|
||||
status: (blame as any).status,
|
||||
blameStatus: (blame as any).blameStatus,
|
||||
workflow: (blame as any).workflow,
|
||||
parties: {
|
||||
first: firstParty
|
||||
? {
|
||||
fullName: firstParty?.person?.fullName,
|
||||
phoneNumber: firstParty?.person?.phoneNumber,
|
||||
role: firstParty?.role,
|
||||
vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle),
|
||||
}
|
||||
: undefined,
|
||||
second: secondParty
|
||||
? {
|
||||
fullName: secondParty?.person?.fullName,
|
||||
phoneNumber: secondParty?.person?.phoneNumber,
|
||||
role: secondParty?.role,
|
||||
vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
expert: (blame as any).expert
|
||||
? {
|
||||
assignedExpertId: (blame as any).expert?.assignedExpertId,
|
||||
decision: (blame as any).expert?.decision,
|
||||
resend: (blame as any).expert?.resend,
|
||||
rating: (blame as any).expert?.rating,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: (blame as any).createdAt,
|
||||
updatedAt: (blame as any).updatedAt,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Keep claim section claim-specific to avoid duplicating shared data already in blame/overview.
|
||||
const claimDetails = claim
|
||||
? {
|
||||
requestId:
|
||||
(claim as any)?._id?.toString?.() || String((claim as any)?._id || ""),
|
||||
requestNo:
|
||||
(claim as any).requestNo || String((claim as any).requestNumber || ""),
|
||||
status: (claim as any).status,
|
||||
claimStatus: (claim as any).claimStatus,
|
||||
owner: (claim as any).owner,
|
||||
vehicle: (claim as any).vehicle,
|
||||
money: (claim as any).money,
|
||||
damage: (claim as any).damage,
|
||||
media: (claim as any).media,
|
||||
requiredDocuments: (claim as any).requiredDocuments,
|
||||
snapshot: (claim as any).snapshot
|
||||
? {
|
||||
...(claim as any).snapshot,
|
||||
parties: claimSnapshotParties?.map((p: any) =>
|
||||
this.sanitizePartyForInsurerView(p),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
workflow: {
|
||||
currentStep: (claim as any).currentStep,
|
||||
nextStep: (claim as any).nextStep,
|
||||
},
|
||||
evaluation: {
|
||||
damageExpertReply: (claim as any).damageExpertReply,
|
||||
damageExpertReplyFinal: (claim as any).damageExpertReplyFinal,
|
||||
damageExpertResend: (claim as any).damageExpertResend,
|
||||
objection: (claim as any).objection,
|
||||
priceDrop: (claim as any).priceDrop,
|
||||
visitLocation: (claim as any).visitLocation,
|
||||
factors: (claim as any).evaluation?.factors,
|
||||
},
|
||||
userResendDocuments: (claim as any).userResendDocuments,
|
||||
userRating: (claim as any).userRating,
|
||||
insurerRating: (claim as any).rating,
|
||||
createdAt: (claim as any).createdAt,
|
||||
updatedAt: (claim as any).updatedAt,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
overview,
|
||||
blame: blameDetails,
|
||||
claim: claimDetails,
|
||||
};
|
||||
}
|
||||
|
||||
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
||||
@@ -372,6 +803,112 @@ export class ExpertInsurerService {
|
||||
return newBranch;
|
||||
}
|
||||
|
||||
private async assertBranchBelongsToClient(
|
||||
branchId: string,
|
||||
clientId: Types.ObjectId,
|
||||
) {
|
||||
const branch = await this.branchDbService.findById(branchId);
|
||||
if (!branch) {
|
||||
throw new NotFoundException("Branch not found");
|
||||
}
|
||||
if (String(branch.clientKey) !== String(clientId)) {
|
||||
throw new ForbiddenException(
|
||||
"Selected branch does not belong to your insurance company.",
|
||||
);
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
private sanitizeExpertResponse(doc: any) {
|
||||
const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc;
|
||||
if (!obj) return obj;
|
||||
const { password, otp, ...rest } = obj;
|
||||
return rest;
|
||||
}
|
||||
|
||||
private async createExpertForInsurer(
|
||||
insurerClientKey: string,
|
||||
payload: CreateInsurerExpertDto,
|
||||
role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT,
|
||||
) {
|
||||
const clientObjectId = this.getClientId(insurerClientKey);
|
||||
const branch = await this.assertBranchBelongsToClient(
|
||||
payload.branchId,
|
||||
clientObjectId,
|
||||
);
|
||||
|
||||
const email = payload.email.trim().toLowerCase();
|
||||
const existingByEmail = await this.expertDbService.findOne({ email });
|
||||
const existingDamageByEmail = await this.damageExpertDbService.findOne({ email });
|
||||
if (existingByEmail || existingDamageByEmail) {
|
||||
throw new ConflictException("An expert with this email already exists.");
|
||||
}
|
||||
|
||||
const nationalCode = payload.nationalCode.trim();
|
||||
const existingByNational = await this.expertDbService.findOne({ nationalCode });
|
||||
const existingDamageByNational = await this.damageExpertDbService.findOne({
|
||||
nationalCode,
|
||||
});
|
||||
if (existingByNational || existingDamageByNational) {
|
||||
throw new ConflictException(
|
||||
"An expert with this national code already exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hashService.hash(payload.password);
|
||||
const basePayload: any = {
|
||||
...payload,
|
||||
email,
|
||||
nationalCode,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
userType: payload.userType ?? UserType.LEGAL,
|
||||
clientKey:
|
||||
role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId,
|
||||
branchId: new Types.ObjectId(payload.branchId),
|
||||
insuActivityCo: String(clientObjectId),
|
||||
};
|
||||
|
||||
const created =
|
||||
role === RoleEnum.EXPERT
|
||||
? await this.expertDbService.create(basePayload)
|
||||
: await this.damageExpertDbService.create(basePayload);
|
||||
|
||||
return {
|
||||
expert: this.sanitizeExpertResponse(created),
|
||||
branch: {
|
||||
_id: (branch as any)._id,
|
||||
name: branch.name,
|
||||
code: branch.code,
|
||||
city: branch.city,
|
||||
state: branch.state,
|
||||
address: branch.address,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async addBlameExpert(
|
||||
insurerClientKey: string,
|
||||
payload: CreateBlameExpertByInsurerDto,
|
||||
) {
|
||||
return this.createExpertForInsurer(
|
||||
insurerClientKey,
|
||||
payload,
|
||||
RoleEnum.EXPERT,
|
||||
);
|
||||
}
|
||||
|
||||
async addClaimExpert(
|
||||
insurerClientKey: string,
|
||||
payload: CreateClaimExpertByInsurerDto,
|
||||
) {
|
||||
return this.createExpertForInsurer(
|
||||
insurerClientKey,
|
||||
payload,
|
||||
RoleEnum.DAMAGE_EXPERT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive statistics for all experts of a client
|
||||
* Returns:
|
||||
@@ -501,4 +1038,113 @@ export class ExpertInsurerService {
|
||||
async retrieveInsuranceBranches(insuranceId: string) {
|
||||
return this.branchDbService.findAll(insuranceId);
|
||||
}
|
||||
|
||||
private parseDateRange(from?: string, to?: string) {
|
||||
const fromDate = from ? new Date(from) : undefined;
|
||||
const toDate = to ? new Date(to) : undefined;
|
||||
|
||||
if (fromDate && Number.isNaN(fromDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'from' date");
|
||||
}
|
||||
if (toDate && Number.isNaN(toDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'to' date");
|
||||
}
|
||||
if (fromDate && toDate && fromDate > toDate) {
|
||||
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||
}
|
||||
return { fromDate, toDate };
|
||||
}
|
||||
|
||||
private isInDateRange(value: any, fromDate?: Date, toDate?: Date): boolean {
|
||||
if (!fromDate && !toDate) return true;
|
||||
if (!value) return false;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
if (fromDate && d < fromDate) return false;
|
||||
if (toDate && d > toDate) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async getInsurerFileStatusCounts(
|
||||
actor: any,
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<{
|
||||
all: number;
|
||||
completed: number;
|
||||
under_review: number;
|
||||
waiting_for_documents_resend: number;
|
||||
}> {
|
||||
const clientObjectId = this.getClientId(actor);
|
||||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||||
|
||||
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
|
||||
this.getClientBlameFiles(clientObjectId),
|
||||
this.getClientClaimFiles(clientObjectId),
|
||||
]);
|
||||
|
||||
const blameFiles = blameFilesRaw.filter((f) =>
|
||||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||||
);
|
||||
const claimFiles = claimFilesRaw.filter((f) =>
|
||||
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
|
||||
);
|
||||
|
||||
const fileMap = new Map<
|
||||
string,
|
||||
{ blame?: any; claim?: any }
|
||||
>();
|
||||
|
||||
for (const b of blameFiles) {
|
||||
const key = String((b as any).publicId || (b as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = fileMap.get(key) ?? {};
|
||||
prev.blame = b;
|
||||
fileMap.set(key, prev);
|
||||
}
|
||||
for (const c of claimFiles) {
|
||||
const key = String((c as any).publicId || (c as any)._id || "");
|
||||
if (!key) continue;
|
||||
const prev = fileMap.get(key) ?? {};
|
||||
prev.claim = c;
|
||||
fileMap.set(key, prev);
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
let underReview = 0;
|
||||
let waitingForDocumentsResend = 0;
|
||||
|
||||
for (const entry of fileMap.values()) {
|
||||
const blameStatus = entry.blame?.status;
|
||||
const claimStatus = entry.claim?.status;
|
||||
|
||||
if (claimStatus === ClaimCaseStatus.COMPLETED) {
|
||||
completed += 1;
|
||||
}
|
||||
|
||||
const blameUnderReview =
|
||||
blameStatus === CaseStatus.WAITING_FOR_EXPERT;
|
||||
const claimUnderReview =
|
||||
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
|
||||
if (blameUnderReview || claimUnderReview) {
|
||||
underReview += 1;
|
||||
}
|
||||
|
||||
const blameResend =
|
||||
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
|
||||
const claimResend =
|
||||
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||
if (blameResend || claimResend) {
|
||||
waitingForDocumentsResend += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all: fileMap.size,
|
||||
completed,
|
||||
under_review: underReview,
|
||||
waiting_for_documents_resend: waitingForDocumentsResend,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum PanelModels {
|
||||
BLAME = "blame",
|
||||
CLAIM = "claim",
|
||||
}
|
||||
142
src/helpers/claim-car-angle-media.ts
Normal file
142
src/helpers/claim-car-angle-media.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Car-angle captures must live under {@code media.carAngles} with lowercase keys
|
||||
* {@code front | back | left | right}. Clients sometimes send Title Case keys or
|
||||
* mistakenly use {@code captureType: "part"} for angles, which previously wrote
|
||||
* into {@code media.damagedParts} and broke step-completion checks.
|
||||
*/
|
||||
|
||||
export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
|
||||
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
|
||||
|
||||
export function canonicalizeClaimCarAngleKey(
|
||||
raw: string | undefined | null,
|
||||
): ClaimCarAngleKey | null {
|
||||
const t = String(raw ?? "").trim().toLowerCase();
|
||||
return (CLAIM_CAR_ANGLE_KEYS as readonly string[]).includes(t)
|
||||
? (t as ClaimCarAngleKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getCaptureBlob(data: unknown, key: string): unknown {
|
||||
if (data == null) return undefined;
|
||||
if (data instanceof Map) return (data as Map<string, unknown>).get(key);
|
||||
return (data as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
/** Prefer carAngles; fall back to legacy mis-placed blobs under damagedParts. */
|
||||
export function getClaimCarAngleCaptureBlob(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): unknown {
|
||||
return (
|
||||
getCaptureBlob(carAnglesData, angle) ??
|
||||
getCaptureBlob(damagedPartsData, angle) ??
|
||||
getCaptureBlob(
|
||||
damagedPartsData,
|
||||
angle.charAt(0).toUpperCase() + angle.slice(1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasClaimCarAngleCapture(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): boolean {
|
||||
return getClaimCarAngleCaptureBlob(carAnglesData, damagedPartsData, angle) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names under media.damagedParts that should be removed when we persist the
|
||||
* same shot under media.carAngles (migration). Skips keys that are also real selected part ids.
|
||||
*/
|
||||
export function legacyAngleDamagedPartFieldKeysToUnset(
|
||||
canonical: ClaimCarAngleKey,
|
||||
rawCaptureKey: string,
|
||||
selectedParts: string[],
|
||||
): string[] {
|
||||
const selected = new Set((selectedParts ?? []).map((p) => String(p)));
|
||||
const variants = new Set(
|
||||
[
|
||||
rawCaptureKey,
|
||||
canonical,
|
||||
canonical.charAt(0).toUpperCase() + canonical.slice(1),
|
||||
].filter(Boolean),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const k of variants) {
|
||||
if (canonicalizeClaimCarAngleKey(k) === null) continue;
|
||||
if (selected.has(k)) continue;
|
||||
out.push(k);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Same English title derived from snake_case keys as capture-requirements UI (legacy client storage). */
|
||||
export function legacyDamagedPartTitleEnFromPartKey(partKey: string): string {
|
||||
return String(partKey)
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeDamagedPartKeyLoose(s: string): string {
|
||||
return String(s).toLowerCase().replace(/[\s_-]/g, "");
|
||||
}
|
||||
|
||||
export function getDamagedPartCaptureBlob(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
): unknown {
|
||||
const pk = String(partKey ?? "").trim();
|
||||
if (!pk || damagedPartsData == null) return undefined;
|
||||
let b = getCaptureBlob(damagedPartsData, pk);
|
||||
if (b) return b;
|
||||
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
|
||||
if (titleEn !== pk) {
|
||||
b = getCaptureBlob(damagedPartsData, titleEn);
|
||||
if (b) return b;
|
||||
}
|
||||
const target = normalizeDamagedPartKeyLoose(pk);
|
||||
const keys =
|
||||
damagedPartsData instanceof Map
|
||||
? Array.from((damagedPartsData as Map<string, unknown>).keys())
|
||||
: Object.keys(damagedPartsData as Record<string, unknown>);
|
||||
for (const k of keys) {
|
||||
if (normalizeDamagedPartKeyLoose(k) === target) {
|
||||
b = getCaptureBlob(damagedPartsData, k);
|
||||
if (b) return b;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a capture exists for catalog/selected part key {@code partKey}, including blobs stored
|
||||
* under humanized keys (e.g. {@code Left Backfender} vs {@code left_backfender}).
|
||||
*/
|
||||
export function hasDamagedPartCapture(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
): boolean {
|
||||
return getDamagedPartCaptureBlob(damagedPartsData, partKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a client {@code captureKey} to the canonical {@code damage.selectedParts} id when unambiguous.
|
||||
*/
|
||||
export function resolveCanonicalDamagedPartStorageKey(
|
||||
captureKeyRaw: string,
|
||||
selectedParts: string[],
|
||||
): string {
|
||||
const raw = String(captureKeyRaw ?? "").trim();
|
||||
if (!raw) return raw;
|
||||
const parts = (selectedParts ?? []).map((p) => String(p).trim()).filter(Boolean);
|
||||
if (parts.includes(raw)) return raw;
|
||||
const n = normalizeDamagedPartKeyLoose(raw);
|
||||
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);
|
||||
if (matches.length === 1) return matches[0];
|
||||
return raw;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||
|
||||
export function resendRequestHasPayload(r: {
|
||||
resendDescription?: string;
|
||||
@@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Normalize expert-requested document keys (strings or { key }). */
|
||||
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
|
||||
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(docs)) return [];
|
||||
const keys: string[] = [];
|
||||
for (const d of docs) {
|
||||
if (typeof d === "string" && d.trim()) keys.push(d.trim());
|
||||
else if (d && typeof d === "object" && "key" in (d as object)) {
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const c = canonicalizeResendDocumentKey(d.trim());
|
||||
if (c) keys.push(c);
|
||||
} else if (d && typeof d === "object" && "key" in (d as object)) {
|
||||
const k = String((d as { key?: string }).key || "").trim();
|
||||
if (k) keys.push(k);
|
||||
const c = canonicalizeResendDocumentKey(k);
|
||||
if (c) keys.push(c);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */
|
||||
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
|
||||
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
const keys: string[] = [];
|
||||
@@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend(
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
|
||||
return allowed.has(documentKey);
|
||||
const normalized =
|
||||
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
|
||||
return allowed.has(normalized);
|
||||
}
|
||||
|
||||
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||
|
||||
35
src/helpers/claim-resend-document-keys.ts
Normal file
35
src/helpers/claim-resend-document-keys.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
|
||||
const REQUIRED_VALUES = new Set<string>(
|
||||
Object.values(ClaimRequiredDocumentType),
|
||||
);
|
||||
|
||||
/**
|
||||
* Non-canonical document keys that may still appear on stored
|
||||
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
|
||||
*/
|
||||
const RESEND_DOCUMENT_KEY_ALIASES: Readonly<
|
||||
Record<string, ClaimRequiredDocumentType>
|
||||
> = {
|
||||
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
};
|
||||
|
||||
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */
|
||||
export function canonicalizeResendDocumentKey(
|
||||
raw: string,
|
||||
): ClaimRequiredDocumentType | null {
|
||||
const t = String(raw || "").trim();
|
||||
if (!t) return null;
|
||||
if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType;
|
||||
return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null;
|
||||
}
|
||||
18
src/main.ts
18
src/main.ts
@@ -32,13 +32,27 @@ async function bootstrap() {
|
||||
.setVersion("1.0.0")
|
||||
.addServer(process.env.URL)
|
||||
.addServer(process.env.BASE_URL + "/api")
|
||||
.addServer("http://192.168.20.249:9001")
|
||||
.addServer("http://192.168.20.170:9001")
|
||||
.addServer("http://localhost:9001")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const docs = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("/docs", app, docs);
|
||||
SwaggerModule.setup("/docs", app, docs, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
docExpansion: "none",
|
||||
tagsSorter: (a: string, b: string) => {
|
||||
const priority: Record<string, number> = {
|
||||
user: 0,
|
||||
actor: 1,
|
||||
};
|
||||
const pa = priority[a] ?? 100;
|
||||
const pb = priority[b] ?? 100;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return a.localeCompare(b);
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.listen(process.env.PORT);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,6 @@ export class ProfileController {
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiBody({ type: AddPlateProfileDto })
|
||||
async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) {
|
||||
console.log(
|
||||
"🚀 ~ file: profile.controller.ts:50 ~ ProfileController ~ addPlate ~ user:",
|
||||
user,
|
||||
);
|
||||
return await this.plateService.addPlate(user.sub, body);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,99 @@
|
||||
/**
|
||||
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
|
||||
* plus `all` = total matching documents for the tenant.
|
||||
* V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
|
||||
* `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
|
||||
*/
|
||||
export class BlameCaseStatusCountReportDtoRs {
|
||||
[key: string]: number;
|
||||
|
||||
constructor(report: Record<string, number>) {
|
||||
Object.assign(this, report);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaimCaseStatusCountReportDtoRs {
|
||||
[key: string]: number;
|
||||
|
||||
constructor(report: Record<string, number>) {
|
||||
Object.assign(this, report);
|
||||
}
|
||||
}
|
||||
|
||||
export class CompanyAllRequestsCountReportDtoRs {
|
||||
blame: Record<string, number>;
|
||||
export class InsurerRequestsSummaryDtoRs {
|
||||
claim: Record<string, number>;
|
||||
blame: Record<string, number>;
|
||||
unifiedByPublicId: { total: number };
|
||||
|
||||
constructor(blame: Record<string, number>, claim: Record<string, number>) {
|
||||
this.blame = blame;
|
||||
constructor(
|
||||
claim: Record<string, number>,
|
||||
blame: Record<string, number>,
|
||||
unifiedTotal: number,
|
||||
) {
|
||||
this.claim = claim;
|
||||
this.blame = blame;
|
||||
this.unifiedByPublicId = { total: unifiedTotal };
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerPerMonthReportRowDtoRs {
|
||||
stDate: Date;
|
||||
enDate: Date;
|
||||
faLabel: string;
|
||||
data: InsurerRequestsSummaryDtoRs;
|
||||
|
||||
constructor(
|
||||
stDate: Date,
|
||||
enDate: Date,
|
||||
faLabel: string,
|
||||
data: InsurerRequestsSummaryDtoRs,
|
||||
) {
|
||||
this.stDate = stDate;
|
||||
this.enDate = enDate;
|
||||
this.faLabel = faLabel;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */
|
||||
export class InsurerExpertWorkLogEntryDtoRs {
|
||||
expertId: string;
|
||||
fullName: string;
|
||||
/**
|
||||
* `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`).
|
||||
* `damage_expert` = claim damage expert (`damage-expert` collection).
|
||||
* Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here.
|
||||
*/
|
||||
expertKind: "expert" | "damage_expert";
|
||||
/** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */
|
||||
totalHandled: number;
|
||||
/** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */
|
||||
currentlyChecking: number;
|
||||
/** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */
|
||||
distinctFilesCheckedInPeriod: number;
|
||||
|
||||
constructor(
|
||||
expertId: string,
|
||||
fullName: string,
|
||||
expertKind: "expert" | "damage_expert",
|
||||
totalHandled: number,
|
||||
currentlyChecking: number,
|
||||
distinctFilesCheckedInPeriod: number,
|
||||
) {
|
||||
this.expertId = expertId;
|
||||
this.fullName = fullName;
|
||||
this.expertKind = expertKind;
|
||||
this.totalHandled = totalHandled;
|
||||
this.currentlyChecking = currentlyChecking;
|
||||
this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerExpertWorkLogResponseDtoRs {
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[];
|
||||
|
||||
constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) {
|
||||
this.experts = experts;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerExpertWorkLogPerMonthRowDtoRs {
|
||||
stDate: Date;
|
||||
enDate: Date;
|
||||
faLabel: string;
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[];
|
||||
|
||||
constructor(
|
||||
stDate: Date,
|
||||
enDate: Date,
|
||||
faLabel: string,
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[],
|
||||
) {
|
||||
this.stDate = stDate;
|
||||
this.enDate = enDate;
|
||||
this.faLabel = faLabel;
|
||||
this.experts = experts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
|
||||
@ApiTags("reports")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY)
|
||||
@Roles(RoleEnum.COMPANY)
|
||||
@Controller("reports")
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@Get("/report/requests")
|
||||
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.reportsService.getAllRequestsReportCount(actor, client);
|
||||
@Get("report/insurer/requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: claim/blame status buckets + unified file count",
|
||||
description:
|
||||
"Claim and blame counts use the same V2 bucket rules as expert panels (IN_PROGRESS grouping), scoped to the insurer JWT tenant. unifiedByPublicId.total counts distinct publicId values like GET expert-insurer/files.",
|
||||
})
|
||||
async getInsurerRequestsSummary(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerRequestsSummary(actor);
|
||||
}
|
||||
|
||||
@Get("/report/perMonthRequests")
|
||||
async getAllRequestsReportPerMonth(
|
||||
@Get("report/insurer/per-month-requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: same summary as report/insurer/requests, per calendar month",
|
||||
description:
|
||||
"Last five calendar months; each slice filters blame/claim rows by createdAt in that month, then claim buckets, blame buckets, and unified publicId count.",
|
||||
})
|
||||
async getInsurerRequestsSummaryPerMonth(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerRequestsSummaryPerMonth(actor);
|
||||
}
|
||||
|
||||
@Get("report/insurer/checked-requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: summary filtered by optional createdAt range",
|
||||
description:
|
||||
"Same payload as report/insurer/requests. When from/to are omitted, all tenant files are included. When provided, only blame/claim rows whose createdAt falls in the range are counted (inclusive).",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getInsurerRequestsSummaryByDateRange(
|
||||
@CurrentUser() actor,
|
||||
@ClientKey() client,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
return await this.reportsService.getAllRequestsReportPerMonth(
|
||||
return await this.reportsService.getInsurerRequestsSummaryByDateRange(
|
||||
actor,
|
||||
client,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("/report/checkedRequests")
|
||||
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.reportsService.getAllCheckedRequestsCount(actor, client);
|
||||
@Get("report/insurer/expert-work-log")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: per-expert work log (blame experts + damage experts)",
|
||||
description:
|
||||
"Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.",
|
||||
})
|
||||
async getInsurerExpertWorkLog(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerExpertWorkLog(actor);
|
||||
}
|
||||
|
||||
@Get("report/insurer/expert-work-log/per-month")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: blame + damage expert work log, last five calendar months",
|
||||
description:
|
||||
"Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that month’s end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).",
|
||||
})
|
||||
async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
@@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service";
|
||||
imports: [
|
||||
RequestManagementModule,
|
||||
ClaimRequestManagementModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
|
||||
@@ -1,197 +1,199 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { requireActorClientKey } from "src/helpers/tenant-scope";
|
||||
import {
|
||||
BlameCaseStatusCountReportDtoRs,
|
||||
ClaimCaseStatusCountReportDtoRs,
|
||||
CompanyAllRequestsCountReportDtoRs,
|
||||
blameCaseStatusToReportBucket,
|
||||
claimCaseStatusToReportBucket,
|
||||
initialBlameExpertReportBuckets,
|
||||
initialClaimExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
blameCaseTouchesClient,
|
||||
claimCaseTouchesClient,
|
||||
requireActorClientKey,
|
||||
} from "src/helpers/tenant-scope";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.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 { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import {
|
||||
InsurerExpertWorkLogPerMonthRowDtoRs,
|
||||
InsurerExpertWorkLogResponseDtoRs,
|
||||
InsurerExpertWorkLogEntryDtoRs,
|
||||
InsurerPerMonthReportRowDtoRs,
|
||||
InsurerRequestsSummaryDtoRs,
|
||||
} from "./dto/reports.dto";
|
||||
|
||||
type NormalizedActivity = {
|
||||
expertId: string;
|
||||
fileId: string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
) {}
|
||||
|
||||
private clientObjectId(client: string | undefined): Types.ObjectId {
|
||||
const ck = requireActorClientKey({ clientKey: client });
|
||||
return new Types.ObjectId(ck);
|
||||
private parseDateRange(from?: string, to?: string) {
|
||||
const fromDate = from ? new Date(from) : undefined;
|
||||
const toDate = to ? new Date(to) : undefined;
|
||||
|
||||
if (fromDate && Number.isNaN(fromDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'from' date");
|
||||
}
|
||||
if (toDate && Number.isNaN(toDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'to' date");
|
||||
}
|
||||
if (fromDate && toDate && fromDate > toDate) {
|
||||
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||
}
|
||||
return { fromDate, toDate };
|
||||
}
|
||||
|
||||
private isBlameForClient(r: any, client: Types.ObjectId): boolean {
|
||||
const idStr = String(client);
|
||||
return (r?.parties || []).some(
|
||||
(p) => String(p?.person?.clientId || "") === idStr,
|
||||
);
|
||||
private isInDateRange(
|
||||
value: unknown,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): boolean {
|
||||
if (!fromDate && !toDate) return true;
|
||||
if (!value) return false;
|
||||
const d = new Date(value as string | Date);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
if (fromDate && d < fromDate) return false;
|
||||
if (toDate && d > toDate) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isClaimForClient(r: any, client: Types.ObjectId): boolean {
|
||||
return String(r?.owner?.userClientKey || "") === String(client);
|
||||
private publicIdKey(doc: Record<string, unknown>): string {
|
||||
return String(doc.publicId ?? doc._id ?? "");
|
||||
}
|
||||
|
||||
private countBlameByCaseStatus(
|
||||
blames: any[],
|
||||
clientId: Types.ObjectId,
|
||||
private buildClaimBucketsForInsurer(
|
||||
rows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = { all: 0 };
|
||||
for (const s of Object.values(CaseStatus)) {
|
||||
out[s] = 0;
|
||||
const buckets = initialClaimExpertReportBuckets();
|
||||
for (const doc of rows) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = claimCaseStatusToReportBucket(st);
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
for (const r of blames) {
|
||||
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;
|
||||
return buckets;
|
||||
}
|
||||
|
||||
private countClaimByCaseStatus(
|
||||
claims: any[],
|
||||
clientId: Types.ObjectId,
|
||||
private buildBlameBucketsForInsurer(
|
||||
rows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = { all: 0 };
|
||||
for (const s of Object.values(ClaimCaseStatus)) {
|
||||
out[s] = 0;
|
||||
const buckets = initialBlameExpertReportBuckets();
|
||||
for (const doc of rows) {
|
||||
if (!blameCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = blameCaseStatusToReportBucket(st);
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
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") {
|
||||
return this.countBlameByCaseStatus(blames as any[], clientId);
|
||||
}
|
||||
|
||||
if (role === "damage_expert") {
|
||||
return this.countClaimByCaseStatus(claims as any[], clientId);
|
||||
}
|
||||
|
||||
if (role === "company") {
|
||||
return {
|
||||
blame: this.countBlameByCaseStatus(blames as any[], clientId),
|
||||
claim: this.countClaimByCaseStatus(claims as any[], clientId),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
|
||||
* Company gets both blame and claim maps; expert / damage_expert get one map.
|
||||
* One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
|
||||
*/
|
||||
async getDateFilteredRequestByStatus(
|
||||
role: string,
|
||||
client: string | undefined,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) {
|
||||
const clientId = this.clientObjectId(client);
|
||||
private countUnifiedByPublicId(
|
||||
blameRows: Record<string, unknown>[],
|
||||
claimRows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): number {
|
||||
const keys = new Set<string>();
|
||||
for (const doc of blameRows) {
|
||||
if (!blameCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const k = this.publicIdKey(doc);
|
||||
if (k) keys.add(k);
|
||||
}
|
||||
for (const doc of claimRows) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const k = this.publicIdKey(doc);
|
||||
if (k) keys.add(k);
|
||||
}
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
private buildInsurerSummary(
|
||||
blames: Record<string, unknown>[],
|
||||
claims: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): InsurerRequestsSummaryDtoRs {
|
||||
const claim = this.buildClaimBucketsForInsurer(
|
||||
claims,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
const blame = this.buildBlameBucketsForInsurer(
|
||||
blames,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
const unifiedTotal = this.countUnifiedByPublicId(
|
||||
blames,
|
||||
claims,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
return new InsurerRequestsSummaryDtoRs(claim, blame, unifiedTotal);
|
||||
}
|
||||
|
||||
async getInsurerRequestsSummary(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerRequestsSummaryDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
|
||||
const inRange = (r: any) => {
|
||||
const createdAt = new Date(r.createdAt);
|
||||
return createdAt >= start && createdAt <= end;
|
||||
};
|
||||
|
||||
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 getAllRequestsReportCount(actor: any, client: string | undefined) {
|
||||
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,
|
||||
return this.buildInsurerSummary(
|
||||
blames as Record<string, unknown>[],
|
||||
claims as Record<string, unknown>[],
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
|
||||
requireActorClientKey(actor);
|
||||
const result: any[] = [];
|
||||
async getInsurerRequestsSummaryPerMonth(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerPerMonthReportRowDtoRs[]> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
const blameRows = blames as Record<string, unknown>[];
|
||||
const claimRows = claims as Record<string, unknown>[];
|
||||
|
||||
const result: InsurerPerMonthReportRowDtoRs[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@@ -209,35 +211,286 @@ export class ReportsService {
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
let data: any;
|
||||
if (actor.role === "company") {
|
||||
data = await this.getDateFilteredRequestByStatus(
|
||||
"company",
|
||||
client,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
} else {
|
||||
data = await this.getDateFilteredRequestByStatus(
|
||||
actor.role,
|
||||
client,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
}
|
||||
const data = this.buildInsurerSummary(
|
||||
blameRows,
|
||||
claimRows,
|
||||
clientKey,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
result.unshift({
|
||||
stDate: monthStart,
|
||||
enDate: monthEnd,
|
||||
faLabel: label,
|
||||
data,
|
||||
});
|
||||
result.unshift(
|
||||
new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
|
||||
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
|
||||
return this.getAllRequestsReportCount(actor, client);
|
||||
async getInsurerRequestsSummaryByDateRange(
|
||||
actor: { clientKey?: string },
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<InsurerRequestsSummaryDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||||
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
return this.buildInsurerSummary(
|
||||
blames as Record<string, unknown>[],
|
||||
claims as Record<string, unknown>[],
|
||||
clientKey,
|
||||
fromDate,
|
||||
toDate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
|
||||
private replayExpertFileStates(
|
||||
events: NormalizedActivity[],
|
||||
cutoff: Date,
|
||||
): Map<string, { checked: boolean; handled: boolean }> {
|
||||
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
const filtered = events.filter((e) => e.occurredAt <= cutoff);
|
||||
filtered.sort((a, b) => {
|
||||
const t = a.occurredAt.getTime() - b.occurredAt.getTime();
|
||||
if (t !== 0) return t;
|
||||
return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`);
|
||||
});
|
||||
for (const e of filtered) {
|
||||
const key = `${e.expertId}:${e.fileId}`;
|
||||
const prev = stateByKey.get(key) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByKey.set(key, prev);
|
||||
}
|
||||
return stateByKey;
|
||||
}
|
||||
|
||||
private snapshotCountsForExpert(
|
||||
stateByKey: Map<string, { checked: boolean; handled: boolean }>,
|
||||
expertId: string,
|
||||
): { totalHandled: number; currentlyChecking: number } {
|
||||
let totalHandled = 0;
|
||||
let currentlyChecking = 0;
|
||||
const prefix = `${expertId}:`;
|
||||
for (const [key, st] of stateByKey.entries()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (st.handled) totalHandled++;
|
||||
else if (st.checked) currentlyChecking++;
|
||||
}
|
||||
return { totalHandled, currentlyChecking };
|
||||
}
|
||||
|
||||
private distinctFilesWithCheckInWindow(
|
||||
events: NormalizedActivity[],
|
||||
expertId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): number {
|
||||
const files = new Set<string>();
|
||||
for (const e of events) {
|
||||
if (e.expertId !== expertId) continue;
|
||||
if (e.eventType !== ExpertFileActivityType.CHECKED) continue;
|
||||
if (e.occurredAt < from || e.occurredAt > to) continue;
|
||||
files.add(e.fileId);
|
||||
}
|
||||
return files.size;
|
||||
}
|
||||
|
||||
private async loadTenantExpertsAndActivities(clientKey: string): Promise<{
|
||||
events: NormalizedActivity[];
|
||||
expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}>;
|
||||
}> {
|
||||
const tenantOid = new Types.ObjectId(clientKey);
|
||||
const tenantStr = String(tenantOid);
|
||||
|
||||
/**
|
||||
* `clientKey` is sometimes stored as ObjectId, sometimes as string — match both.
|
||||
* Activity is loaded for the whole tenant so claim (V2) damage experts still appear
|
||||
* even if a roster row was missed by shape mismatches; we then merge `_id` lookups.
|
||||
*/
|
||||
const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([
|
||||
this.expertDbService.findAll({
|
||||
$or: [{ clientKey: tenantStr }, { clientKey: tenantOid }],
|
||||
} as never),
|
||||
this.damageExpertDbService.findAll({
|
||||
$or: [{ clientKey: tenantOid }, { clientKey: tenantStr }],
|
||||
} as never),
|
||||
this.expertFileActivityDbService.findByTenant(tenantOid),
|
||||
]);
|
||||
|
||||
const expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}> = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => {
|
||||
if (seenIds.has(id)) return;
|
||||
seenIds.add(id);
|
||||
expertRows.push({ id, fullName, kind });
|
||||
};
|
||||
|
||||
for (const e of blamePanelExperts) {
|
||||
const fullName =
|
||||
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
|
||||
(e as any).email ||
|
||||
String((e as any)._id);
|
||||
pushRow(String((e as any)._id), fullName, "expert");
|
||||
}
|
||||
for (const e of damageExperts) {
|
||||
const fullName =
|
||||
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
|
||||
(e as any).email ||
|
||||
String((e as any)._id);
|
||||
pushRow(String((e as any)._id), fullName, "damage_expert");
|
||||
}
|
||||
|
||||
const activityExpertIds = [
|
||||
...new Set(
|
||||
rawEvents
|
||||
.map((row) => String(row.expertId))
|
||||
.filter((id) => Types.ObjectId.isValid(id)),
|
||||
),
|
||||
].filter((id) => !seenIds.has(id));
|
||||
|
||||
if (activityExpertIds.length > 0) {
|
||||
const oids = activityExpertIds.map((id) => new Types.ObjectId(id));
|
||||
const [extraDamages, extraExperts] = await Promise.all([
|
||||
this.damageExpertDbService.findAll({ _id: { $in: oids } } as never),
|
||||
this.expertDbService.findAll({ _id: { $in: oids } } as never),
|
||||
]);
|
||||
for (const d of extraDamages) {
|
||||
const id = String((d as any)._id);
|
||||
if (seenIds.has(id)) continue;
|
||||
const fullName =
|
||||
`${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() ||
|
||||
(d as any).email ||
|
||||
id;
|
||||
pushRow(id, fullName, "damage_expert");
|
||||
}
|
||||
for (const x of extraExperts) {
|
||||
const id = String((x as any)._id);
|
||||
if (seenIds.has(id)) continue;
|
||||
const fullName =
|
||||
`${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() ||
|
||||
(x as any).email ||
|
||||
id;
|
||||
pushRow(id, fullName, "expert");
|
||||
}
|
||||
}
|
||||
|
||||
const events: NormalizedActivity[] = rawEvents.map((row) => ({
|
||||
expertId: String(row.expertId),
|
||||
fileId: String(row.fileId),
|
||||
eventType: row.eventType,
|
||||
occurredAt:
|
||||
row.occurredAt instanceof Date
|
||||
? row.occurredAt
|
||||
: new Date(row.occurredAt as string | Date),
|
||||
}));
|
||||
|
||||
return { events, expertRows };
|
||||
}
|
||||
|
||||
private buildWorkLogEntries(
|
||||
expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}>,
|
||||
events: NormalizedActivity[],
|
||||
cutoff: Date,
|
||||
checkedWindow: { from: Date; to: Date },
|
||||
): InsurerExpertWorkLogEntryDtoRs[] {
|
||||
const state = this.replayExpertFileStates(events, cutoff);
|
||||
return expertRows.map((row) => {
|
||||
const snap = this.snapshotCountsForExpert(state, row.id);
|
||||
return new InsurerExpertWorkLogEntryDtoRs(
|
||||
row.id,
|
||||
row.fullName,
|
||||
row.kind,
|
||||
snap.totalHandled,
|
||||
snap.currentlyChecking,
|
||||
this.distinctFilesWithCheckInWindow(
|
||||
events,
|
||||
row.id,
|
||||
checkedWindow.from,
|
||||
checkedWindow.to,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getInsurerExpertWorkLog(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { events, expertRows } =
|
||||
await this.loadTenantExpertsAndActivities(clientKey);
|
||||
const now = new Date();
|
||||
const epoch = new Date(0);
|
||||
const entries = this.buildWorkLogEntries(expertRows, events, now, {
|
||||
from: epoch,
|
||||
to: now,
|
||||
});
|
||||
return new InsurerExpertWorkLogResponseDtoRs(entries);
|
||||
}
|
||||
|
||||
async getInsurerExpertWorkLogPerMonth(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { events, expertRows } =
|
||||
await this.loadTenantExpertsAndActivities(clientKey);
|
||||
const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = [];
|
||||
const now = new Date();
|
||||
|
||||
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 experts = this.buildWorkLogEntries(
|
||||
expertRows,
|
||||
events,
|
||||
monthEnd,
|
||||
{ from: monthStart, to: monthEnd },
|
||||
);
|
||||
|
||||
result.unshift(
|
||||
new InsurerExpertWorkLogPerMonthRowDtoRs(
|
||||
monthStart,
|
||||
monthEnd,
|
||||
label,
|
||||
experts,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ export class Workflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@Prop({ type: ActorLockSchema })
|
||||
lockedBy?: ActorLock;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Get,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
@@ -48,6 +46,7 @@ export class ExpertInitiatedController {
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("my-files")
|
||||
@ApiOperation({
|
||||
summary: "List my expert-initiated files",
|
||||
@@ -61,6 +60,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-blame-data/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit all blame-needed data",
|
||||
@@ -86,6 +86,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-claim-data/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit claim-needed data",
|
||||
@@ -111,6 +112,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary: "Create expert-initiated blame file",
|
||||
@@ -138,6 +140,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-form-third-party/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert completes all information for IN_PERSON THIRD_PARTY file",
|
||||
@@ -167,6 +170,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-form-car-body/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert completes all information for IN_PERSON CAR_BODY file",
|
||||
@@ -196,6 +200,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-video/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert uploads video for expert-initiated file",
|
||||
@@ -252,6 +257,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-voice/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert uploads voice for expert-initiated file",
|
||||
@@ -309,6 +315,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("add-accident-fields/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert adds accident fields to expert-initiated file",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RequestManagementController } from "./request-management.controller";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
|
||||
describe("RequestManagementController", () => {
|
||||
let controller: RequestManagementController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [RequestManagementController],
|
||||
providers: [RequestManagementService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<RequestManagementController>(
|
||||
RequestManagementController,
|
||||
);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
Req,
|
||||
Put,
|
||||
UploadedFiles,
|
||||
Patch,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
FileFieldsInterceptor,
|
||||
@@ -23,8 +21,8 @@ import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import { diskStorage } from "multer";
|
||||
@@ -44,7 +42,6 @@ import {
|
||||
LocationDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { InPersonVisitDto } from "src/claim-request-management/dto/in-person-visit.dto";
|
||||
|
||||
@Controller("blame-request-management")
|
||||
@ApiTags("blame-request-management")
|
||||
@@ -56,6 +53,7 @@ export class RequestManagementController {
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post()
|
||||
@UseGuards(GlobalGuard)
|
||||
async createRequest(
|
||||
@@ -68,6 +66,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/initial-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: InitialFormDto })
|
||||
@@ -115,6 +114,7 @@ export class RequestManagementController {
|
||||
)
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-video/:requestId")
|
||||
@UseGuards(GlobalGuard)
|
||||
async videoUploader(
|
||||
@@ -129,6 +129,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/add-plate/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: AddPlateDto })
|
||||
@@ -146,6 +147,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/carbody-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodyFormDto })
|
||||
@@ -163,6 +165,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/carbody-secondform/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodySecondForm })
|
||||
@@ -180,6 +183,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/add-detail-location/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: LocationDto })
|
||||
@@ -228,6 +232,7 @@ export class RequestManagementController {
|
||||
}),
|
||||
)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-voice/:requestId")
|
||||
@UseGuards(GlobalGuard)
|
||||
async voiceUploader(
|
||||
@@ -242,6 +247,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/add-detail-description/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: DescriptionDto })
|
||||
@@ -259,6 +265,7 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("add-second-party-details/:phoneNumber/:requestId/:frontendRoutes")
|
||||
@ApiParam({ name: "phoneNumber" })
|
||||
@ApiParam({ name: "requestId" })
|
||||
@@ -280,17 +287,20 @@ export class RequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("requests")
|
||||
async requests(@CurrentUser() user) {
|
||||
return await this.requestManagementService.myRequests(user.username);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("creatable-claims")
|
||||
@UseGuards(GlobalGuard)
|
||||
async getCreatableClaims(@CurrentUser() user) {
|
||||
return await this.requestManagementService.getCreatableClaims(user);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@Roles(RoleEnum.USER)
|
||||
@@ -329,6 +339,7 @@ export class RequestManagementController {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/reply/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@UseGuards(GlobalGuard)
|
||||
@@ -385,6 +396,7 @@ export class RequestManagementController {
|
||||
)
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/resend/:requestId")
|
||||
userResendReply(
|
||||
@Body("description") description: string,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
|
||||
describe("RequestManagementService", () => {
|
||||
let service: RequestManagementService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [RequestManagementService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RequestManagementService>(RequestManagementService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,10 @@ import { UserSignatureResponseDto } from "./dto/user-signature.dto";
|
||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
||||
import {
|
||||
buildResendItemsWithUi,
|
||||
cloneResendUploadedDocuments,
|
||||
isResendPartyItemSatisfied,
|
||||
normalizeResendRequestedItemKey,
|
||||
normalizeResendRequestedItemsList,
|
||||
} from "src/Types&Enums/blame-request-management/resend-item-ui";
|
||||
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";
|
||||
@@ -71,6 +74,11 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-
|
||||
import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema";
|
||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
ExpertFileKind,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { UploadContext } from "./entities/schema/blame-voice.schema";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
@@ -309,6 +317,7 @@ export class RequestManagementService {
|
||||
private readonly userSign: UserSignDbService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly autoCloseRequestService: AutoCloseRequestService,
|
||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
@@ -2370,9 +2379,6 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) {
|
||||
console.log(
|
||||
new Date(p.createdAt).toLocaleTimeString("fa-IR").split(","),
|
||||
);
|
||||
res.listOfFirstParty.push({
|
||||
id: p.id,
|
||||
blameStatus: p.blameStatus,
|
||||
@@ -2746,76 +2752,28 @@ export class RequestManagementService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async updateDamageExpertStats(
|
||||
expertId: string,
|
||||
requestId: string,
|
||||
type: "checked" | "handled",
|
||||
) {
|
||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
||||
console.warn("Invalid expertId, requestId, or type");
|
||||
private async recordBlameExpertActivity(args: {
|
||||
expertId: string;
|
||||
tenantId: string;
|
||||
requestId: string;
|
||||
eventType: ExpertFileActivityType;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!Types.ObjectId.isValid(args.expertId) ||
|
||||
!Types.ObjectId.isValid(args.tenantId) ||
|
||||
!Types.ObjectId.isValid(args.requestId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that both expertId and requestId are valid ObjectId strings
|
||||
if (!Types.ObjectId.isValid(expertId)) {
|
||||
console.warn("Invalid ObjectId format for expertId:", expertId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Types.ObjectId.isValid(requestId)) {
|
||||
console.warn("Invalid ObjectId format for requestId:", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const expert = await this.expertDbService.findOne({
|
||||
_id: new Types.ObjectId(expertId),
|
||||
await this.expertFileActivityDbService.recordEvent({
|
||||
expertId: args.expertId,
|
||||
tenantId: args.tenantId,
|
||||
fileId: args.requestId,
|
||||
fileType: ExpertFileKind.BLAME,
|
||||
eventType: args.eventType,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
});
|
||||
|
||||
if (!expert) {
|
||||
console.warn("Expert not found:", expertId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestIdStr = new Types.ObjectId(requestId).toString();
|
||||
const countedRequestIds =
|
||||
expert.countedRequests?.map((id) => id.toString()) || [];
|
||||
|
||||
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
|
||||
console.log(
|
||||
`Request ${requestIdStr} already checked for expert ${expertId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const update: any = { $inc: {}, $push: {} };
|
||||
|
||||
if (type === "checked") {
|
||||
update.$inc["requestStats.totalChecked"] = 1;
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else if (type === "handled") {
|
||||
update.$inc["requestStats.totalHandled"] = 1;
|
||||
|
||||
if (countedRequestIds.includes(requestIdStr)) {
|
||||
update.$inc["requestStats.totalChecked"] = -1;
|
||||
}
|
||||
|
||||
if (!countedRequestIds.includes(requestIdStr)) {
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else {
|
||||
delete update.$push;
|
||||
}
|
||||
}
|
||||
|
||||
const updateResult = await this.expertDbService.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(expertId) },
|
||||
update,
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
console.warn("Failed to update expert stats for:", expertId);
|
||||
} else {
|
||||
console.log(`Expert stats updated (${type}) for expert:`, expertId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDisagreement(
|
||||
@@ -3006,11 +2964,17 @@ export class RequestManagementService {
|
||||
request._id.toString() :
|
||||
String(request._id);
|
||||
|
||||
await this.updateDamageExpertStats(
|
||||
actorIdStr,
|
||||
requestIdStr,
|
||||
"handled",
|
||||
);
|
||||
const tenantIdStr =
|
||||
request.firstPartyDetails?.firstPartyClient?.clientId?.toString?.() ||
|
||||
request.secondPartyDetails?.secondPartyClient?.clientId?.toString?.() ||
|
||||
"";
|
||||
await this.recordBlameExpertActivity({
|
||||
expertId: actorIdStr,
|
||||
tenantId: tenantIdStr,
|
||||
requestId: requestIdStr,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
idempotencyKey: `blame:${requestIdStr}:handled:${actorIdStr}`,
|
||||
});
|
||||
}
|
||||
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
|
||||
$set: { isHandledStatsUpdated: true },
|
||||
@@ -6079,14 +6043,12 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
const items = (userResendRequest.requestedItems || []) as string[];
|
||||
const items = normalizeResendRequestedItemsList(
|
||||
userResendRequest.requestedItems as string[],
|
||||
);
|
||||
const row = userResendRequest as any;
|
||||
const merged = {
|
||||
uploadedDocuments: {
|
||||
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
|
||||
? { ...row.uploadedDocuments }
|
||||
: {}),
|
||||
},
|
||||
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
|
||||
resendVoiceId: row.resendVoiceId,
|
||||
resendVideoId: row.resendVideoId,
|
||||
userTextDescription: row.userTextDescription,
|
||||
@@ -6095,7 +6057,7 @@ export class RequestManagementService {
|
||||
|
||||
return {
|
||||
requestId: String(request._id),
|
||||
requestedItems: userResendRequest.requestedItems,
|
||||
requestedItems: items,
|
||||
description: userResendRequest.description || "",
|
||||
completed: userResendRequest.completed || false,
|
||||
requestedItemsUi: buildResendItemsWithUi(items),
|
||||
@@ -6161,22 +6123,26 @@ export class RequestManagementService {
|
||||
}
|
||||
|
||||
const userResendRequest = resendParties[resendIndex];
|
||||
const requestedItems = userResendRequest.requestedItems || [];
|
||||
const requestedItems = normalizeResendRequestedItemsList(
|
||||
userResendRequest.requestedItems as string[],
|
||||
);
|
||||
if (requestedItems.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"No valid requested items are configured for your party resend. Please contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const row = userResendRequest as any;
|
||||
const mergedRow = {
|
||||
uploadedDocuments: {
|
||||
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
|
||||
? { ...row.uploadedDocuments }
|
||||
: {}),
|
||||
},
|
||||
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
|
||||
resendVoiceId: row.resendVoiceId,
|
||||
resendVideoId: row.resendVideoId,
|
||||
userTextDescription: row.userTextDescription,
|
||||
};
|
||||
|
||||
const hasAnyFile = Object.keys(files || {}).some((fieldName) => {
|
||||
const fileArray = files[fieldName as keyof typeof files];
|
||||
const safeFiles = files || {};
|
||||
const hasAnyFile = Object.keys(safeFiles).some((fieldName) => {
|
||||
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
|
||||
return fileArray && fileArray.length > 0 && fileArray[0];
|
||||
});
|
||||
const descTrim =
|
||||
@@ -6186,7 +6152,7 @@ export class RequestManagementService {
|
||||
|
||||
if (!hasAnyFile && !sendingDescription) {
|
||||
throw new BadRequestException(
|
||||
"Provide at least one requested file or a text description when description was requested.",
|
||||
"Provide at least one file for a requested item and/or the text description when the expert asked for a description.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6201,19 +6167,20 @@ export class RequestManagementService {
|
||||
uploadedItems.push(ResendItemType.DESCRIPTION);
|
||||
}
|
||||
|
||||
for (const fieldName in files) {
|
||||
const fileArray = files[fieldName as keyof typeof files];
|
||||
for (const fieldName of Object.keys(safeFiles)) {
|
||||
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
|
||||
if (!fileArray || fileArray.length === 0) continue;
|
||||
|
||||
const file = fileArray[0];
|
||||
|
||||
if (!requestedItems.includes(fieldName)) {
|
||||
const canonKey = normalizeResendRequestedItemKey(fieldName);
|
||||
if (!canonKey || !requestedItems.includes(canonKey)) {
|
||||
throw new BadRequestException(
|
||||
`${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`,
|
||||
`${fieldName} was not requested or is not a valid resend field. Requested items: ${requestedItems.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldName === "voice") {
|
||||
if (canonKey === ResendItemType.VOICE) {
|
||||
const voiceDoc = await this.blameVoiceDbService.create({
|
||||
path: file.path,
|
||||
requestId: new Types.ObjectId(requestId),
|
||||
@@ -6227,8 +6194,8 @@ export class RequestManagementService {
|
||||
`expert.resend.parties.${resendIndex}.resendVoiceId`
|
||||
] = (voiceDoc as any)._id;
|
||||
mergedRow.resendVoiceId = (voiceDoc as any)._id;
|
||||
uploadedItems.push(fieldName);
|
||||
} else if (fieldName === "video") {
|
||||
uploadedItems.push(canonKey);
|
||||
} else if (canonKey === ResendItemType.VIDEO) {
|
||||
const videoDoc = await this.blameVideoDbService.create({
|
||||
path: file.path,
|
||||
requestId: new Types.ObjectId(requestId),
|
||||
@@ -6240,9 +6207,9 @@ export class RequestManagementService {
|
||||
`expert.resend.parties.${resendIndex}.resendVideoId`
|
||||
] = (videoDoc as any)._id;
|
||||
mergedRow.resendVideoId = (videoDoc as any)._id;
|
||||
uploadedItems.push(fieldName);
|
||||
uploadedItems.push(canonKey);
|
||||
} else {
|
||||
const docType = fieldName as BlameDocumentType;
|
||||
const docType = canonKey as BlameDocumentType;
|
||||
const doc = await this.blameDocumentDbService.create({
|
||||
path: file.path,
|
||||
requestId: new Types.ObjectId(requestId),
|
||||
@@ -6250,16 +6217,16 @@ export class RequestManagementService {
|
||||
documentType: docType,
|
||||
});
|
||||
updatePayload.$set[
|
||||
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
|
||||
`expert.resend.parties.${resendIndex}.uploadedDocuments.${canonKey}`
|
||||
] = (doc as any)._id;
|
||||
mergedRow.uploadedDocuments[fieldName] = (doc as any)._id;
|
||||
uploadedItems.push(fieldName);
|
||||
mergedRow.uploadedDocuments[canonKey] = (doc as any)._id;
|
||||
uploadedItems.push(canonKey);
|
||||
}
|
||||
}
|
||||
|
||||
const allItemsCompleted = (requestedItems as string[]).every((item) =>
|
||||
isResendPartyItemSatisfied(item, mergedRow),
|
||||
);
|
||||
const allItemsCompleted =
|
||||
requestedItems.length > 0 &&
|
||||
requestedItems.every((item) => isResendPartyItemSatisfied(item, mergedRow));
|
||||
|
||||
if (allItemsCompleted) {
|
||||
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true;
|
||||
@@ -6270,7 +6237,7 @@ export class RequestManagementService {
|
||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||||
|
||||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||||
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
|
||||
const allPartiesCompleted = updatedRequest?.expert?.resend?.parties?.every(
|
||||
(p) => p.completed,
|
||||
);
|
||||
|
||||
@@ -6446,6 +6413,41 @@ export class RequestManagementService {
|
||||
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
updatedRequest.type === BlameRequestType.THIRD_PARTY ||
|
||||
updatedRequest.type === BlameRequestType.CAR_BODY
|
||||
) {
|
||||
let targetPhone: string | undefined;
|
||||
|
||||
if (updatedRequest.type === BlameRequestType.THIRD_PARTY) {
|
||||
const guiltyPartyId = updatedRequest.expert?.decision?.guiltyPartyId
|
||||
? String(updatedRequest.expert.decision.guiltyPartyId)
|
||||
: null;
|
||||
if (guiltyPartyId) {
|
||||
const damagedParty = updatedRequest.parties?.find(
|
||||
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
|
||||
);
|
||||
targetPhone = damagedParty?.person?.phoneNumber?.trim();
|
||||
}
|
||||
} else {
|
||||
// CAR_BODY has a single owner/damaged party: notify that user too.
|
||||
const ownerParty = updatedRequest.parties?.[partyIndex] ?? updatedRequest.parties?.[0];
|
||||
targetPhone = ownerParty?.person?.phoneNumber?.trim();
|
||||
}
|
||||
|
||||
if (targetPhone) {
|
||||
const publicId = String(updatedRequest.publicId);
|
||||
const link = this.smsOrchestrationService.buildClaimLink(requestId);
|
||||
await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice(
|
||||
{
|
||||
receptor: targetPhone,
|
||||
publicId,
|
||||
link,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
finalStatus = CaseStatus.STOPPED;
|
||||
message =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { extname } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -398,6 +399,11 @@ export class RequestManagementV2Controller {
|
||||
description:
|
||||
"Text description when expert requested ResendItemType.description",
|
||||
},
|
||||
textDescription: {
|
||||
type: "string",
|
||||
description:
|
||||
"Alias for `description` (same semantics); either field may be used.",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -414,7 +420,9 @@ export class RequestManagementV2Controller {
|
||||
{
|
||||
storage: diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, "./uploads");
|
||||
const uploadDir = "./files/blame-resend";
|
||||
mkdirSync(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix =
|
||||
@@ -439,12 +447,18 @@ export class RequestManagementV2Controller {
|
||||
video?: Express.Multer.File[];
|
||||
},
|
||||
@Body("description") description?: string,
|
||||
@Body("textDescription") textDescription?: string,
|
||||
) {
|
||||
const descCandidates = [description, textDescription]
|
||||
.map((x) => (x != null ? String(x).trim() : ""))
|
||||
.filter((s) => s.length > 0);
|
||||
const mergedDescription =
|
||||
descCandidates.length > 0 ? descCandidates[0] : undefined;
|
||||
return this.requestManagementService.uploadResendDocumentsV2(
|
||||
requestId,
|
||||
user.sub,
|
||||
files,
|
||||
description,
|
||||
mergedDescription,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface VerifyLookUpMessage {
|
||||
token: string;
|
||||
token2?: string;
|
||||
token3?: string;
|
||||
token10?: string;
|
||||
receptor: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ type TemplateArgs = {
|
||||
token: string;
|
||||
token2?: string;
|
||||
token3?: string;
|
||||
token10?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -40,6 +41,15 @@ export class SmsOrchestrationService implements OnModuleInit {
|
||||
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
||||
}
|
||||
|
||||
buildBlamePartyLink(requestId: string, partyRole: "FIRST" | "SECOND"): string {
|
||||
const route = partyRole === "SECOND" ? "user2" : "user";
|
||||
return `${process.env.URL}/${route}?token=${requestId}`;
|
||||
}
|
||||
|
||||
buildClaimLink(claimRequestId: string): string {
|
||||
return `${process.env.URL}/caseClaim?token=${claimRequestId}`;
|
||||
}
|
||||
|
||||
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template: "yara724-invite-link",
|
||||
@@ -107,20 +117,66 @@ export class SmsOrchestrationService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
/** THIRD_PARTY only: notify the damaged (non-guilty) party that blame is completed and they can open the claim flow. */
|
||||
async sendThirdPartyDamagedPartyClaimLinkNotice(params: {
|
||||
receptor: string;
|
||||
publicId: string;
|
||||
link: string;
|
||||
}): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template: "yara-claim-link",
|
||||
receptor: params.receptor,
|
||||
token: params.publicId,
|
||||
token2: params.link,
|
||||
});
|
||||
}
|
||||
|
||||
async sendThirdPartyExpertStartedReviewNotice(
|
||||
params: {
|
||||
receptor: string;
|
||||
fileKind: "blame" | "claim";
|
||||
publicId: string;
|
||||
expertLastName: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template:
|
||||
process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK ||
|
||||
"yara-blame-expert-lock",
|
||||
template: "yara-expert-lock",
|
||||
receptor: params.receptor,
|
||||
token: params.publicId,
|
||||
token2: params.expertLastName || "کارشناس",
|
||||
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
|
||||
token2: params.publicId,
|
||||
token3: params.expertLastName || "کارشناس",
|
||||
});
|
||||
}
|
||||
|
||||
async sendResendDocumentsNotice(params: {
|
||||
receptor: string;
|
||||
fileKind: "blame" | "claim";
|
||||
publicId: string;
|
||||
link: string;
|
||||
}): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template: "yara-resend-documents",
|
||||
receptor: params.receptor,
|
||||
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
|
||||
token2: params.publicId,
|
||||
token3: params.link,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignatureReviewNotice(params: {
|
||||
receptor: string;
|
||||
fileKind: "blame" | "claim";
|
||||
publicId: string;
|
||||
expertLastName: string;
|
||||
link: string;
|
||||
}): Promise<boolean> {
|
||||
return this.sendTemplate({
|
||||
template: "yara-signature",
|
||||
receptor: params.receptor,
|
||||
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
|
||||
token2: params.publicId,
|
||||
token3: params.expertLastName || "کارشناس",
|
||||
token10: params.link,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
73
src/static/outer-car-parts-catalog.ts
Normal file
73
src/static/outer-car-parts-catalog.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export enum ClaimVehicleTypeV2 {
|
||||
SEDAN = "sedan",
|
||||
SUV = "suv",
|
||||
HATCHBACK = "hatchback",
|
||||
PICKUP = "pickup",
|
||||
VAN = "van",
|
||||
}
|
||||
|
||||
export enum OuterPartSideV2 {
|
||||
LEFT = "left",
|
||||
RIGHT = "right",
|
||||
FRONT = "front",
|
||||
BACK = "back",
|
||||
TOP = "top",
|
||||
}
|
||||
|
||||
export type OuterPartCatalogItem = {
|
||||
id: number;
|
||||
key: string;
|
||||
titleFa: string;
|
||||
side: OuterPartSideV2;
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
};
|
||||
|
||||
const baseFor = (offset: number): OuterPartCatalogItem[] => [
|
||||
// left
|
||||
{ id: offset + 1, key: "left_backfender", titleFa: "گلگیر عقب", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 2, key: "left_backWheel", titleFa: "چرخ عقب", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 3, key: "left_backDoor", titleFa: "در عقب", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 4, key: "left_frontDoor", titleFa: "در جلو", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 5, key: "left_mirror", titleFa: "آیینه", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 6, key: "left_frontWheel", titleFa: "چرخ جلو", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 7, key: "left_frontFender", titleFa: "گلگیر جلو", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 8, key: "left_backWindow", titleFa: "شیشه عقب", side: OuterPartSideV2.LEFT },
|
||||
{ id: offset + 9, key: "left_frontWindow", titleFa: "شیشه جلو", side: OuterPartSideV2.LEFT },
|
||||
// right
|
||||
{ id: offset + 10, key: "right_backfender", titleFa: "گلگیر عقب", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 11, key: "right_backWheel", titleFa: "چرخ عقب", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 12, key: "right_backDoor", titleFa: "در عقب", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 13, key: "right_frontDoor", titleFa: "در جلو", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 14, key: "right_mirror", titleFa: "آیینه", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 15, key: "right_frontWheel", titleFa: "چرخ جلو", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 16, key: "right_frontFender", titleFa: "گلگیر جلو", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 17, key: "right_backWindow", titleFa: "شیشه عقب", side: OuterPartSideV2.RIGHT },
|
||||
{ id: offset + 18, key: "right_frontWindow", titleFa: "شیشه جلو", side: OuterPartSideV2.RIGHT },
|
||||
// front
|
||||
{ id: offset + 19, key: "front_frontBumper", titleFa: "سپر جلو", side: OuterPartSideV2.FRONT },
|
||||
{ id: offset + 20, key: "front_frontCarWindshield", titleFa: "شیشه جلو", side: OuterPartSideV2.FRONT },
|
||||
{ id: offset + 21, key: "front_carHood", titleFa: "کاپوت", side: OuterPartSideV2.FRONT },
|
||||
{ id: offset + 22, key: "front_leftLight", titleFa: "چراغ چپ", side: OuterPartSideV2.FRONT },
|
||||
{ id: offset + 23, key: "front_rightLight", titleFa: "چراغ راست", side: OuterPartSideV2.FRONT },
|
||||
{ id: offset + 24, key: "front_frontGrille", titleFa: "جلو پنجره", side: OuterPartSideV2.FRONT },
|
||||
// back
|
||||
{ id: offset + 25, key: "back_backBumper", titleFa: "سپر عقب", side: OuterPartSideV2.BACK },
|
||||
{ id: offset + 26, key: "back_carTrunk", titleFa: "صندوق عقب", side: OuterPartSideV2.BACK },
|
||||
{ id: offset + 27, key: "back_backCarWindshield", titleFa: "شیشه عقب", side: OuterPartSideV2.BACK },
|
||||
{ id: offset + 28, key: "back_leftLight", titleFa: "چراغ چپ", side: OuterPartSideV2.BACK },
|
||||
{ id: offset + 29, key: "back_rightLight", titleFa: "چراغ راست", side: OuterPartSideV2.BACK },
|
||||
// top
|
||||
{ id: offset + 30, key: "top_roof", titleFa: "سقف", side: OuterPartSideV2.TOP },
|
||||
];
|
||||
|
||||
export const OUTER_PARTS_BY_CAR_TYPE: Record<
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartCatalogItem[]
|
||||
> = {
|
||||
[ClaimVehicleTypeV2.SEDAN]: baseFor(0),
|
||||
[ClaimVehicleTypeV2.SUV]: baseFor(100),
|
||||
[ClaimVehicleTypeV2.HATCHBACK]: baseFor(200),
|
||||
[ClaimVehicleTypeV2.PICKUP]: baseFor(300),
|
||||
[ClaimVehicleTypeV2.VAN]: baseFor(400),
|
||||
};
|
||||
|
||||
@@ -46,64 +46,6 @@ export class DamageExpertDbService {
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateStats(
|
||||
expertId: string,
|
||||
type: "checked" | "handled",
|
||||
requestId: string,
|
||||
) {
|
||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
||||
console.warn("Invalid expertId, requestId, or type");
|
||||
return;
|
||||
}
|
||||
|
||||
const expert = await this.damageExpertModel.findById(expertId);
|
||||
if (!expert) {
|
||||
console.warn("Expert not found:", expertId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestIdStr = new Types.ObjectId(requestId).toString();
|
||||
const countedRequests =
|
||||
expert.countedRequests?.map((r) => r.toString()) || [];
|
||||
|
||||
if (type === "checked" && countedRequests.includes(requestIdStr)) {
|
||||
console.log(
|
||||
`Request ${requestIdStr} already checked for expert ${expertId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const update: any = { $inc: {}, $push: {} };
|
||||
|
||||
if (type === "checked") {
|
||||
update.$inc["requestStats.totalChecked"] = 1;
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else if (type === "handled") {
|
||||
update.$inc["requestStats.totalHandled"] = 1;
|
||||
|
||||
if (countedRequests.includes(requestIdStr)) {
|
||||
update.$inc["requestStats.totalChecked"] = -1;
|
||||
}
|
||||
|
||||
if (!countedRequests.includes(requestIdStr)) {
|
||||
update.$push["countedRequests"] = requestIdStr;
|
||||
} else {
|
||||
delete update.$push;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.damageExpertModel.findByIdAndUpdate(
|
||||
expertId,
|
||||
update,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
console.warn("Failed to update stats for expert:", expertId);
|
||||
} else {
|
||||
console.log(`Stats updated (${type}) for expert:`, expertId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOne(filter: any, update: any) {
|
||||
return this.damageExpertModel.updateOne(filter, update);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model, Types } from "mongoose";
|
||||
import {
|
||||
ExpertFileActivity,
|
||||
ExpertFileActivityDocument,
|
||||
ExpertFileActivityType,
|
||||
ExpertFileKind,
|
||||
} from "../schema/expert-file-activity.schema";
|
||||
|
||||
@Injectable()
|
||||
export class ExpertFileActivityDbService {
|
||||
constructor(
|
||||
@InjectModel(ExpertFileActivity.name)
|
||||
private readonly activityModel: Model<ExpertFileActivityDocument>,
|
||||
) {}
|
||||
|
||||
async recordEvent(args: {
|
||||
expertId: string | Types.ObjectId;
|
||||
tenantId: string | Types.ObjectId;
|
||||
fileId: string | Types.ObjectId;
|
||||
fileType: ExpertFileKind;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt?: Date;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<void> {
|
||||
const payload = {
|
||||
expertId: new Types.ObjectId(String(args.expertId)),
|
||||
tenantId: new Types.ObjectId(String(args.tenantId)),
|
||||
fileId: new Types.ObjectId(String(args.fileId)),
|
||||
fileType: args.fileType,
|
||||
eventType: args.eventType,
|
||||
occurredAt: args.occurredAt ?? new Date(),
|
||||
...(args.idempotencyKey ? { idempotencyKey: args.idempotencyKey } : {}),
|
||||
};
|
||||
|
||||
if (args.idempotencyKey) {
|
||||
await this.activityModel.updateOne(
|
||||
{ idempotencyKey: args.idempotencyKey },
|
||||
{ $setOnInsert: payload },
|
||||
{ upsert: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.activityModel.create(payload);
|
||||
}
|
||||
|
||||
async findByTenantAndExperts(
|
||||
tenantId: string | Types.ObjectId,
|
||||
expertIds: Array<string | Types.ObjectId>,
|
||||
): Promise<
|
||||
Array<{
|
||||
expertId: Types.ObjectId;
|
||||
fileId: Types.ObjectId;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}>
|
||||
> {
|
||||
if (expertIds.length === 0) return [];
|
||||
const ids = expertIds.map((id) => new Types.ObjectId(String(id)));
|
||||
return this.activityModel
|
||||
.find(
|
||||
{
|
||||
tenantId: new Types.ObjectId(String(tenantId)),
|
||||
expertId: { $in: ids },
|
||||
},
|
||||
{ expertId: 1, fileId: 1, eventType: 1, occurredAt: 1 },
|
||||
)
|
||||
.sort({ occurredAt: 1, _id: 1 })
|
||||
.lean();
|
||||
}
|
||||
|
||||
/** All file-activity rows for an insurer tenant (blame + claim experts). */
|
||||
async findByTenant(tenantId: string | Types.ObjectId): Promise<
|
||||
Array<{
|
||||
expertId: Types.ObjectId;
|
||||
fileId: Types.ObjectId;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}>
|
||||
> {
|
||||
return this.activityModel
|
||||
.find(
|
||||
{ tenantId: new Types.ObjectId(String(tenantId)) },
|
||||
{ expertId: 1, fileId: 1, eventType: 1, occurredAt: 1 },
|
||||
)
|
||||
.sort({ occurredAt: 1, _id: 1 })
|
||||
.lean();
|
||||
}
|
||||
}
|
||||
@@ -33,50 +33,6 @@ export class ExpertDbService {
|
||||
return await this.expertModel.find(filter).lean();
|
||||
}
|
||||
|
||||
async updateStats(
|
||||
expertId: string,
|
||||
type: "checked" | "handled",
|
||||
requestId: string,
|
||||
) {
|
||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
||||
console.warn("Invalid expertId, requestId, or type");
|
||||
return;
|
||||
}
|
||||
|
||||
const expert = await this.expertModel.findById(expertId);
|
||||
if (!expert) {
|
||||
console.warn("Expert not found:", expertId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestIdStr = new Types.ObjectId(requestId).toString();
|
||||
const counted = (expert.countedRequests || []).map((r) => r.toString());
|
||||
|
||||
if (counted.includes(requestIdStr)) {
|
||||
console.log("Request already counted for expert:", requestIdStr);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateField =
|
||||
type === "handled"
|
||||
? {
|
||||
"requestStats.totalHandled": 1,
|
||||
"requestStats.totalChecked": -1,
|
||||
}
|
||||
: { "requestStats.totalChecked": 1 };
|
||||
|
||||
const result = await this.expertModel.findByIdAndUpdate(expertId, {
|
||||
$inc: updateField,
|
||||
$push: { countedRequests: requestIdStr },
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
console.warn("Failed to update expert stats for:", expertId);
|
||||
} else {
|
||||
console.log("Updated stats for expert:", expertId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOne(filter: any, update: any) {
|
||||
return this.expertModel.updateOne(filter, update);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,6 @@ import {
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequestStats {
|
||||
@Prop({ default: 0 })
|
||||
totalHandled: number;
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalChecked: number;
|
||||
}
|
||||
|
||||
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class PersonalInfo {
|
||||
@@ -133,6 +123,9 @@ export class DamageExpertModel {
|
||||
@Prop({ type: Types.ObjectId })
|
||||
clientKey?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: PersonalInfoSchema })
|
||||
personalInfo?: PersonalInfo;
|
||||
|
||||
@@ -166,19 +159,6 @@ export class DamageExpertModel {
|
||||
@Prop({ type: "string" })
|
||||
city: string;
|
||||
|
||||
@Prop({ type: RequestStatsSchema, default: () => ({}) })
|
||||
requestStats: RequestStats;
|
||||
|
||||
@Prop({
|
||||
type: [{ type: String }],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
|
||||
message: "All countedRequests must be strings",
|
||||
},
|
||||
})
|
||||
countedRequests?: string[];
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -190,11 +170,4 @@ DamageExpertDbSchema.pre("save", function (next) {
|
||||
this.username = this.email;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
DamageExpertDbSchema.pre("save", function (next) {
|
||||
if (!this.requestStats) {
|
||||
this.requestStats = { totalChecked: 0, totalHandled: 0 };
|
||||
}
|
||||
next();
|
||||
});
|
||||
});
|
||||
52
src/users/entities/schema/expert-file-activity.schema.ts
Normal file
52
src/users/entities/schema/expert-file-activity.schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument, Types } from "mongoose";
|
||||
|
||||
export type ExpertFileActivityDocument = HydratedDocument<ExpertFileActivity>;
|
||||
|
||||
export enum ExpertFileActivityType {
|
||||
CHECKED = "checked",
|
||||
HANDLED = "handled",
|
||||
UNCHECKED = "unchecked",
|
||||
}
|
||||
|
||||
export enum ExpertFileKind {
|
||||
BLAME = "blame",
|
||||
CLAIM = "claim",
|
||||
}
|
||||
|
||||
@Schema({ collection: "expertFileActivities", timestamps: true })
|
||||
export class ExpertFileActivity {
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
expertId: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
tenantId: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||
fileId: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, enum: ExpertFileKind, required: true, index: true })
|
||||
fileType: ExpertFileKind;
|
||||
|
||||
@Prop({
|
||||
type: String,
|
||||
enum: ExpertFileActivityType,
|
||||
required: true,
|
||||
index: true,
|
||||
})
|
||||
eventType: ExpertFileActivityType;
|
||||
|
||||
@Prop({ type: Date, default: Date.now, index: true })
|
||||
occurredAt: Date;
|
||||
|
||||
@Prop({ type: String, required: false })
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export const ExpertFileActivitySchema =
|
||||
SchemaFactory.createForClass(ExpertFileActivity);
|
||||
|
||||
ExpertFileActivitySchema.index(
|
||||
{ idempotencyKey: 1 },
|
||||
{ unique: true, partialFilterExpression: { idempotencyKey: { $exists: true } } },
|
||||
);
|
||||
@@ -1,19 +1,9 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { Degrees } from "src/Types&Enums/degrees.enum";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequestStats {
|
||||
@Prop({ default: 0 })
|
||||
totalHandled: number;
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalChecked: number;
|
||||
}
|
||||
|
||||
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
|
||||
|
||||
@Schema({ collection: "expert", versionKey: false, timestamps: true })
|
||||
export class ExpertModel {
|
||||
@Prop({})
|
||||
@@ -76,30 +66,14 @@ export class ExpertModel {
|
||||
@Prop({})
|
||||
clientKey?: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: "string" })
|
||||
otp: string;
|
||||
|
||||
@Prop({ type: RequestStatsSchema, default: () => ({}) })
|
||||
requestStats: RequestStats;
|
||||
|
||||
@Prop({
|
||||
type: [{ type: String }],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
|
||||
message: "All countedRequests must be strings",
|
||||
},
|
||||
})
|
||||
countedRequests?: string[];
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const ExpertDbSchema = SchemaFactory.createForClass(ExpertModel);
|
||||
|
||||
ExpertDbSchema.pre("save", function (next) {
|
||||
if (!this.requestStats) {
|
||||
this.requestStats = { totalChecked: 0, totalHandled: 0 };
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -2,17 +2,6 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequestStats {
|
||||
@Prop({ default: 0 })
|
||||
totalHandled: number;
|
||||
|
||||
@Prop({ default: 0 })
|
||||
totalChecked: number;
|
||||
}
|
||||
|
||||
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
|
||||
|
||||
@Schema({
|
||||
collection: "field-expert",
|
||||
versionKey: false,
|
||||
@@ -48,31 +37,8 @@ export class FieldExpertModel {
|
||||
@Prop({ type: "string", default: "" })
|
||||
otp: string;
|
||||
|
||||
@Prop({ type: RequestStatsSchema, default: () => ({}) })
|
||||
requestStats: RequestStats;
|
||||
|
||||
@Prop({
|
||||
type: [{ type: String }],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
|
||||
message: "All countedRequests must be strings",
|
||||
},
|
||||
})
|
||||
countedRequests?: string[];
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const FieldExpertDbSchema =
|
||||
SchemaFactory.createForClass(FieldExpertModel);
|
||||
|
||||
FieldExpertDbSchema.pre("save", function (next) {
|
||||
if (!this.username) {
|
||||
this.username = this.email;
|
||||
}
|
||||
if (!this.requestStats) {
|
||||
this.requestStats = { totalChecked: 0, totalHandled: 0 };
|
||||
}
|
||||
next();
|
||||
});
|
||||
SchemaFactory.createForClass(FieldExpertModel);
|
||||
@@ -9,10 +9,15 @@ import { FieldExpertDbService } from "./entities/db-service/field-expert.db.serv
|
||||
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
|
||||
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
|
||||
import { UserDbService } from "./entities/db-service/user.db.service";
|
||||
import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
DamageExpertDbSchema,
|
||||
DamageExpertModel,
|
||||
} from "./entities/schema/damage-expert.schema";
|
||||
import {
|
||||
ExpertFileActivity,
|
||||
ExpertFileActivitySchema,
|
||||
} from "./entities/schema/expert-file-activity.schema";
|
||||
import { ExpertDbSchema } from "./entities/schema/expert.schema";
|
||||
import {
|
||||
FieldExpertDbSchema,
|
||||
@@ -37,6 +42,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
||||
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
|
||||
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
|
||||
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
|
||||
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
|
||||
]),
|
||||
OtpModule,
|
||||
HashModule,
|
||||
@@ -49,6 +55,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
||||
FieldExpertDbService,
|
||||
InsurerExpertDbService,
|
||||
RegistrarDbService,
|
||||
ExpertFileActivityDbService,
|
||||
],
|
||||
exports: [
|
||||
UserDbService,
|
||||
@@ -57,6 +64,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
||||
FieldExpertDbService,
|
||||
InsurerExpertDbService,
|
||||
RegistrarDbService,
|
||||
ExpertFileActivityDbService,
|
||||
],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
163
test-sms.js
163
test-sms.js
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Test script for sending SMS via Kavenegar API
|
||||
*
|
||||
* Usage:
|
||||
* node test-sms.js
|
||||
*
|
||||
* Make sure to update the API_KEY and RECEPTOR (your phone number) below
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const querystring = require('querystring');
|
||||
|
||||
// ===== CONFIGURATION =====
|
||||
// Replace with your actual Kavenegar API key
|
||||
const API_KEY = '75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E'; // Replace with your actual API key
|
||||
|
||||
// Replace with your phone number (format: 09*********)
|
||||
const RECEPTOR = '09199503061'; // Replace with your actual phone number
|
||||
|
||||
// Optional: Sender number (if not provided, uses default account sender)
|
||||
// const SENDER = '10004346'; // Optional - can be removed if you want to use default
|
||||
|
||||
// Test message
|
||||
const MESSAGE = 'تست ارسال پیامک از سیستم Yara724';
|
||||
// ===== END CONFIGURATION =====
|
||||
|
||||
function sendSMS(receptor, message, sender = null, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Build URL
|
||||
const baseUrl = `https://api.kavenegar.com/v1/${API_KEY}/sms/send.json`;
|
||||
|
||||
// Build query parameters
|
||||
const params = {
|
||||
receptor: receptor,
|
||||
message: encodeURIComponent(message), // Encode message for URL
|
||||
};
|
||||
|
||||
// Only add sender if it's provided (not null/undefined/empty)
|
||||
if (sender && sender.trim() !== '') {
|
||||
params.sender = sender;
|
||||
}
|
||||
|
||||
// Add optional parameters
|
||||
if (options.date) params.date = options.date;
|
||||
if (options.type) params.type = options.type;
|
||||
if (options.localid) params.localid = options.localid;
|
||||
if (options.hide) params.hide = options.hide;
|
||||
if (options.tag) params.tag = options.tag;
|
||||
if (options.policy) params.policy = options.policy;
|
||||
|
||||
const queryString = querystring.stringify(params);
|
||||
const url = `${baseUrl}?${queryString}`;
|
||||
|
||||
console.log('\n📤 Sending SMS...');
|
||||
console.log('URL:', url.replace(API_KEY, '***API_KEY***'));
|
||||
console.log('Receptor:', receptor);
|
||||
console.log('Message:', message);
|
||||
console.log('Sender:', sender || '(Using default account sender)');
|
||||
|
||||
// Make HTTPS request
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
|
||||
if (response.return && response.return.status === 200) {
|
||||
console.log('\n✅ SMS sent successfully!');
|
||||
console.log('Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (response.entries && response.entries.length > 0) {
|
||||
console.log('\n📊 SMS Details:');
|
||||
response.entries.forEach((entry, index) => {
|
||||
console.log(`\n Message ${index + 1}:`);
|
||||
console.log(` Message ID: ${entry.messageid}`);
|
||||
console.log(` Status: ${entry.status} (${entry.statustext})`);
|
||||
console.log(` Receptor: ${entry.receptor}`);
|
||||
console.log(` Sender: ${entry.sender}`);
|
||||
console.log(` Cost: ${entry.cost} Rials`);
|
||||
console.log(` Date: ${new Date(entry.date * 1000).toLocaleString('fa-IR')}`);
|
||||
});
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
} else {
|
||||
console.error('\n❌ Error sending SMS');
|
||||
console.error('Response:', JSON.stringify(response, null, 2));
|
||||
reject(new Error(`API Error: ${response.return?.message || 'Unknown error'}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error parsing response:', error.message);
|
||||
console.error('Raw response:', data);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
console.error('\n❌ Network error:', error.message);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test function
|
||||
*/
|
||||
async function testSMS() {
|
||||
console.log('='.repeat(50));
|
||||
console.log('🧪 Kavenegar SMS Test Script');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Validate configuration
|
||||
if (API_KEY === '613472435563797A37677331D' || API_KEY.length < 10) {
|
||||
console.error('\n⚠️ WARNING: Please update API_KEY with your actual Kavenegar API key!');
|
||||
console.error(' You can find it in your Kavenegar panel: https://panel.kavenegar.com/client/membership/api');
|
||||
}
|
||||
|
||||
if (RECEPTOR === '09123456789' || !RECEPTOR.startsWith('09')) {
|
||||
console.error('\n⚠️ WARNING: Please update RECEPTOR with your actual phone number!');
|
||||
console.error(' Format: 09********* (11 digits starting with 09)');
|
||||
}
|
||||
|
||||
try {
|
||||
// Test 1: Simple SMS (without sender - uses default account sender)
|
||||
console.log('\n\n📝 Test 1: Simple SMS (No sender - using default)');
|
||||
await sendSMS(RECEPTOR, MESSAGE);
|
||||
|
||||
// Wait a bit before next test
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Test 2: SMS with tag (optional - uncomment if you have tags set up)
|
||||
// console.log('\n\n📝 Test 2: SMS with Tag');
|
||||
// await sendSMS(RECEPTOR, 'تست پیامک با تگ', SENDER, { tag: 'test' });
|
||||
|
||||
// Test 3: Multiple recipients (uncomment to test)
|
||||
// console.log('\n\n📝 Test 3: Multiple Recipients');
|
||||
// const multipleReceptors = `${RECEPTOR},09123456789`; // Add more numbers separated by comma
|
||||
// await sendSMS(multipleReceptors, 'تست پیامک به چندین گیرنده', SENDER);
|
||||
|
||||
console.log('\n\n' + '='.repeat(50));
|
||||
console.log('✅ All tests completed!');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n\n❌ Test failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
if (require.main === module) {
|
||||
testSMS().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendSMS };
|
||||
|
||||
Reference in New Issue
Block a user