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";
|
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). */
|
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||||
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
||||||
|
|
||||||
@@ -11,7 +61,8 @@ export function getResendItemInputKind(item: string): ResendItemInputKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildResendItemsWithUi(requestedItems: string[]) {
|
export function buildResendItemsWithUi(requestedItems: string[]) {
|
||||||
return requestedItems.map((item) => ({
|
const normalized = normalizeResendRequestedItemsList(requestedItems);
|
||||||
|
return normalized.map((item) => ({
|
||||||
item,
|
item,
|
||||||
inputKind: getResendItemInputKind(item),
|
inputKind: getResendItemInputKind(item),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
|
|||||||
CarCertificate = "carCertificate",
|
CarCertificate = "carCertificate",
|
||||||
DrivingLicense = "drivingLicense",
|
DrivingLicense = "drivingLicense",
|
||||||
CarGreenCard = "carGreenCard",
|
CarGreenCard = "carGreenCard",
|
||||||
|
Plate = "plate",
|
||||||
|
CarPlate = "carPlate",
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
|
|||||||
username: process.env.AI_USERNAME,
|
username: process.env.AI_USERNAME,
|
||||||
password: process.env.AI_PASSWORD,
|
password: process.env.AI_PASSWORD,
|
||||||
},
|
},
|
||||||
timeout: 30000, // 30 second timeout
|
timeout: 1000, // 30 second timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
private get profileOptions(): AxiosRequestConfig {
|
private get profileOptions(): AxiosRequestConfig {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
|
||||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiParam({ name: "blameId" })
|
@ApiParam({ name: "blameId" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/:blameId")
|
@Post("/:blameId")
|
||||||
async createClaimRequest(
|
async createClaimRequest(
|
||||||
@Param("blameId") requestId: string,
|
@Param("blameId") requestId: string,
|
||||||
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({ type: CarDamagePartDto })
|
@ApiBody({ type: CarDamagePartDto })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("/car-part-damage/:claimRequestID")
|
@Patch("/car-part-damage/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
@ApiParam({ name: "claimRequestID" })
|
||||||
async carPartDamage(
|
async carPartDamage(
|
||||||
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("/car-other-part")
|
@Get("/car-other-part")
|
||||||
async getCarOtherParts() {
|
async getCarOtherParts() {
|
||||||
const carOtherPart = await readFile(
|
const carOtherPart = await readFile(
|
||||||
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("/car-other-part-damage/:claimRequestID")
|
@Patch("/car-other-part-damage/:claimRequestID")
|
||||||
async carOtherPartDamage(
|
async carOtherPartDamage(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("required-documents-status/:claimRequestID")
|
@Get("required-documents-status/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
@ApiParam({ name: "claimRequestID" })
|
||||||
async getRequiredDocumentsStatus(
|
async getRequiredDocumentsStatus(
|
||||||
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("car-part-image-required/:claimRequestID")
|
@Get("car-part-image-required/:claimRequestID")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
@ApiParam({ name: "claimRequestID" })
|
||||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||||
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
|
|||||||
enum: ClaimRequiredDocumentType,
|
enum: ClaimRequiredDocumentType,
|
||||||
description: "Type of required document to upload",
|
description: "Type of required document to upload",
|
||||||
})
|
})
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("upload-required-document/:claimRequestID")
|
@Patch("upload-required-document/:claimRequestID")
|
||||||
async uploadRequiredDocument(
|
async uploadRequiredDocument(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
|
|||||||
name: "partId",
|
name: "partId",
|
||||||
description: "The ID of the specific car part being photographed.",
|
description: "The ID of the specific car part being photographed.",
|
||||||
})
|
})
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||||
async captureCarPartDamage(
|
async captureCarPartDamage(
|
||||||
@Param("partId") partId: string,
|
@Param("partId") partId: string,
|
||||||
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
|
|||||||
)
|
)
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestID" })
|
@ApiParam({ name: "claimRequestID" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("car-capture/:claimRequestID")
|
@Patch("car-capture/:claimRequestID")
|
||||||
async captureVideoCapture(
|
async captureVideoCapture(
|
||||||
@Param("claimRequestID") requestId: string,
|
@Param("claimRequestID") requestId: string,
|
||||||
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("requests/")
|
@Get("requests/")
|
||||||
async getRequest(@CurrentUser() currentUser) {
|
async getRequest(@CurrentUser() currentUser) {
|
||||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("request/:claimRequestId")
|
@Get("request/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
myRequests(
|
myRequests(
|
||||||
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
|
|||||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("request/reply/:claimRequestId")
|
@Put("request/reply/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("request/resend/:claimRequestId/objection")
|
@Put("request/resend/:claimRequestId/objection")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiConsumes("application/json")
|
@ApiConsumes("application/json")
|
||||||
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("request/resend/:claimRequestId")
|
@Patch("request/resend/:claimRequestId")
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
|
|||||||
* User satisfaction rating for a completed claim file.
|
* User satisfaction rating for a completed claim file.
|
||||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||||
*/
|
*/
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("request/:claimRequestId/user-rating")
|
@Put("request/:claimRequestId/user-rating")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@ApiBody({ type: UserRatingDto })
|
@ApiBody({ type: UserRatingDto })
|
||||||
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
|
|||||||
|
|
||||||
@ApiBody({ type: InPersonVisitDto })
|
@ApiBody({ type: InPersonVisitDto })
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(
|
async inPersonVisit(
|
||||||
@Param("id") requestId: string,
|
@Param("id") requestId: string,
|
||||||
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("fanavaran-submit/:claimRequestId")
|
@Get("fanavaran-submit/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||||
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("fanavaran-submit/:claimRequestId")
|
@Post("fanavaran-submit/:claimRequestId")
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ import {
|
|||||||
import { PublicIdService } from "src/utils/public-id/public-id.service";
|
import { PublicIdService } from "src/utils/public-id/public-id.service";
|
||||||
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
|
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
|
||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
|
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||||
|
import {
|
||||||
|
ExpertFileActivityType,
|
||||||
|
ExpertFileKind,
|
||||||
|
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
|
||||||
@@ -79,6 +84,22 @@ import {
|
|||||||
normalizeResendPartKeys,
|
normalizeResendPartKeys,
|
||||||
partKeyAllowedForExpertResend,
|
partKeyAllowedForExpertResend,
|
||||||
} from "src/helpers/claim-expert-resend";
|
} 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()
|
@Injectable()
|
||||||
export class ClaimRequestManagementService {
|
export class ClaimRequestManagementService {
|
||||||
@@ -117,6 +138,7 @@ export class ClaimRequestManagementService {
|
|||||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||||
private readonly publicIdService: PublicIdService,
|
private readonly publicIdService: PublicIdService,
|
||||||
@@ -126,6 +148,26 @@ export class ClaimRequestManagementService {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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) {
|
private userDamageDetail(blRequest: RequestManagementModel) {
|
||||||
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
|
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
|
||||||
switch (blRequest.expertSubmitReply.guiltyUserId) {
|
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(
|
async submitUserReply(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
body: UserCommentDto,
|
body: UserCommentDto,
|
||||||
@@ -2158,6 +2284,7 @@ export class ClaimRequestManagementService {
|
|||||||
return {
|
return {
|
||||||
message: "Factor uploaded successfully. Awaiting expert validation.",
|
message: "Factor uploaded successfully. Awaiting expert validation.",
|
||||||
factorId: factorRecord._id,
|
factorId: factorRecord._id,
|
||||||
|
factorLink: buildFileLink(file.path),
|
||||||
url: buildFileLink(file.path),
|
url: buildFileLink(file.path),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2577,11 +2704,20 @@ export class ClaimRequestManagementService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.damageExpertDbService.updateStats(
|
if (
|
||||||
actorDetail.sub,
|
Types.ObjectId.isValid(String(actorDetail.sub)) &&
|
||||||
"handled",
|
Types.ObjectId.isValid(String(request.userClientKey)) &&
|
||||||
requestId,
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -2680,6 +2816,27 @@ export class ClaimRequestManagementService {
|
|||||||
}, 0);
|
}, 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
|
* 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)
|
// 5. Validate by selected car type and resolve selected parts by ids/keys
|
||||||
// Backward compatibility: some clients may still send legacy `carPartDamage` object.
|
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)
|
let selectedParts = Array.isArray((body as any)?.selectedParts)
|
||||||
? ((body as any).selectedParts as string[])
|
? ((body as any).selectedParts as string[])
|
||||||
: [];
|
: [];
|
||||||
@@ -3732,17 +3918,52 @@ export class ClaimRequestManagementService {
|
|||||||
(body as any).carPartDamage as CarDamagePartDto,
|
(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(
|
throw new BadRequestException(
|
||||||
"selectedParts is required and must contain at least one item",
|
`Invalid outer part key for ${selectedCarType}: ${key}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
selectedFromKeys.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItems =
|
||||||
|
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
|
||||||
|
if (selectedItems.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"selectedPartIds or selectedParts is required and must contain at least one item",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 6. Update claim case with selected parts and move to next step
|
||||||
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
{
|
{
|
||||||
'damage.selectedParts': selectedParts,
|
'damage.selectedParts': selectedParts,
|
||||||
|
'damage.selectedOuterParts': selectedOuterParts,
|
||||||
|
'vehicle.carType': selectedCarType,
|
||||||
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||||
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
@@ -3759,6 +3980,8 @@ export class ClaimRequestManagementService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||||
selectedParts: selectedParts,
|
selectedParts: selectedParts,
|
||||||
|
selectedPartIds,
|
||||||
|
carType: selectedCarType,
|
||||||
partsCount: selectedParts?.length,
|
partsCount: selectedParts?.length,
|
||||||
description: `User selected ${selectedParts?.length} damaged outer parts`,
|
description: `User selected ${selectedParts?.length} damaged outer parts`,
|
||||||
},
|
},
|
||||||
@@ -3780,6 +4003,7 @@ export class ClaimRequestManagementService {
|
|||||||
claimRequestId: updatedClaim._id.toString(),
|
claimRequestId: updatedClaim._id.toString(),
|
||||||
publicId: updatedClaim.publicId,
|
publicId: updatedClaim.publicId,
|
||||||
selectedParts: selectedParts,
|
selectedParts: selectedParts,
|
||||||
|
selectedPartIds,
|
||||||
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||||
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
|
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');
|
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 getPartLabel = (key: string): { fa: string; en: string } => {
|
||||||
const labels = {
|
const catalogItem = catalogByKey.get(key);
|
||||||
hood: { fa: 'کاپوت', en: 'Hood' },
|
if (catalogItem) {
|
||||||
trunk: { fa: 'صندوق عقب', en: 'Trunk' },
|
const en = key
|
||||||
roof: { fa: 'سقف', en: 'Roof' },
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
front_right_door: { fa: 'درب جلو راست', en: 'Front Right Door' },
|
.replace(/_/g, " ")
|
||||||
front_left_door: { fa: 'درب جلو چپ', en: 'Front Left Door' },
|
.trim()
|
||||||
rear_right_door: { fa: 'درب عقب راست', en: 'Rear Right Door' },
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
rear_left_door: { fa: 'درب عقب چپ', en: 'Rear Left Door' },
|
return { fa: catalogItem.titleFa, en };
|
||||||
front_bumper: { fa: 'سپر جلو', en: 'Front Bumper' },
|
}
|
||||||
rear_bumper: { fa: 'سپر عقب', en: 'Rear Bumper' },
|
return { fa: key, en: key };
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
// 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,
|
key: angle.key,
|
||||||
label_fa: angle.label_fa,
|
label_fa: angle.label_fa,
|
||||||
label_en: angle.label_en,
|
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
|
// Damaged parts from selected parts
|
||||||
const selectedParts = claimCase.damage?.selectedParts || [];
|
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 damagedParts = selectedParts.map(partKey => {
|
||||||
const label = getPartLabel(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 {
|
return {
|
||||||
|
id: selectedMeta?.id ?? catalogMeta?.id,
|
||||||
key: partKey,
|
key: partKey,
|
||||||
label_fa: label.fa,
|
side,
|
||||||
|
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa,
|
||||||
label_en: label.en,
|
label_en: label.en,
|
||||||
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
|
captured: hasDamagedPartCapture(
|
||||||
|
claimCase.media?.damagedParts as any,
|
||||||
|
partKey,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4489,7 +4753,23 @@ export class ClaimRequestManagementService {
|
|||||||
`Part "${body.captureKey}" was not requested by the damage expert for this resend.`,
|
`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 fileUrl = buildFileLink(file.path);
|
||||||
const captureData = {
|
const captureData = {
|
||||||
@@ -4502,7 +4782,7 @@ export class ClaimRequestManagementService {
|
|||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
type: body.captureType === 'angle' ? 'ANGLE_CAPTURED' : 'PART_CAPTURED',
|
type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED",
|
||||||
actor: {
|
actor: {
|
||||||
actorId: new Types.ObjectId(currentUserId),
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
actorName: claimCase.owner?.fullName || 'User',
|
actorName: claimCase.owner?.fullName || 'User',
|
||||||
@@ -4512,19 +4792,51 @@ export class ClaimRequestManagementService {
|
|||||||
metadata: {
|
metadata: {
|
||||||
captureType: body.captureType,
|
captureType: body.captureType,
|
||||||
captureKey: body.captureKey,
|
captureKey: body.captureKey,
|
||||||
|
...(treatAsAngle && angleCanon
|
||||||
|
? { resolvedCarAngleKey: angleCanon }
|
||||||
|
: {}),
|
||||||
fileName: file.filename,
|
fileName: file.filename,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body.captureType === 'angle') {
|
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
|
||||||
updateData[`media.carAngles.${body.captureKey}`] = captureData;
|
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 {
|
} 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 (
|
if (
|
||||||
isResendCapture &&
|
isResendCapture &&
|
||||||
body.captureType === "part" &&
|
body.captureType === "part" &&
|
||||||
@@ -4570,8 +4882,16 @@ export class ClaimRequestManagementService {
|
|||||||
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
|
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
|
||||||
const selectedParts = updatedClaim?.damage?.selectedParts || [];
|
const selectedParts = updatedClaim?.damage?.selectedParts || [];
|
||||||
|
|
||||||
const anglesCaptured = anglesKeys.filter(k => hasCapture(carAnglesData, k)).length;
|
const anglesCaptured = anglesKeys.filter((k) =>
|
||||||
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
|
hasClaimCarAngleCapture(
|
||||||
|
carAnglesData,
|
||||||
|
damagedPartsData,
|
||||||
|
k as ClaimCarAngleKey,
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
const partsCaptured = selectedParts.filter((p) =>
|
||||||
|
hasDamagedPartCapture(damagedPartsData, p),
|
||||||
|
).length;
|
||||||
|
|
||||||
const allCapturesComplete =
|
const allCapturesComplete =
|
||||||
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
|
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
|
||||||
@@ -4906,6 +5226,182 @@ export class ClaimRequestManagementService {
|
|||||||
return userRating;
|
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).
|
* 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 carAnglesData = claim.media?.carAngles as any;
|
||||||
|
const damagedPartsForAngles = claim.media?.damagedParts as any;
|
||||||
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
|
||||||
for (const k of ['front', 'back', 'left', 'right']) {
|
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] = {
|
carAngles[k] = {
|
||||||
captured: !!cap,
|
captured: !!cap,
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
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 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(
|
const resendPartKeys = normalizeResendPartKeys(
|
||||||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||||||
);
|
);
|
||||||
@@ -5019,8 +5542,11 @@ export class ClaimRequestManagementService {
|
|||||||
...resendPartKeys,
|
...resendPartKeys,
|
||||||
]);
|
]);
|
||||||
for (const p of partKeysForDetails) {
|
for (const p of partKeysForDetails) {
|
||||||
const cap = hasCapture(damagedPartsData, p);
|
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as
|
||||||
|
| { url?: string; path?: string }
|
||||||
|
| undefined;
|
||||||
damagedParts[p] = {
|
damagedParts[p] = {
|
||||||
|
label_fa: getPartLabelFa(p),
|
||||||
captured: !!cap,
|
captured: !!cap,
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
||||||
};
|
};
|
||||||
@@ -5045,6 +5571,10 @@ export class ClaimRequestManagementService {
|
|||||||
const ownerData = claim.owner
|
const ownerData = claim.owner
|
||||||
? {
|
? {
|
||||||
userId: claim.owner.userId?.toString(),
|
userId: claim.owner.userId?.toString(),
|
||||||
|
clientId: claim.owner.clientId?.toString(),
|
||||||
|
...(claim.owner.userClientKey
|
||||||
|
? { userClientKey: claim.owner.userClientKey.toString() }
|
||||||
|
: {}),
|
||||||
fullName: claim.owner.fullName,
|
fullName: claim.owner.fullName,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -5061,6 +5591,8 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId: claim._id.toString(),
|
claimRequestId: claim._id.toString(),
|
||||||
publicId: claim.publicId,
|
publicId: claim.publicId,
|
||||||
@@ -5071,7 +5603,7 @@ export class ClaimRequestManagementService {
|
|||||||
nextStep: claim.workflow?.nextStep,
|
nextStep: claim.workflow?.nextStep,
|
||||||
blameRequestId: claim.blameRequestId?.toString(),
|
blameRequestId: claim.blameRequestId?.toString(),
|
||||||
blameRequestNo: claim.blameRequestNo,
|
blameRequestNo: claim.blameRequestNo,
|
||||||
...(isExpertViewer ? { owner: ownerData } : {}),
|
...(ownerData ? { owner: ownerData } : {}),
|
||||||
vehicle: claim.vehicle,
|
vehicle: claim.vehicle,
|
||||||
selectedParts: claim.damage?.selectedParts,
|
selectedParts: claim.damage?.selectedParts,
|
||||||
otherParts: claim.damage?.otherParts,
|
otherParts: claim.damage?.otherParts,
|
||||||
@@ -5080,6 +5612,12 @@ export class ClaimRequestManagementService {
|
|||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
expertResend,
|
expertResend,
|
||||||
|
evaluation: mappedEvaluation
|
||||||
|
? {
|
||||||
|
damageExpertReply: mappedEvaluation.damageExpertReply,
|
||||||
|
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
...(isExpertViewer
|
...(isExpertViewer
|
||||||
? {
|
? {
|
||||||
userRating: claim.userRating
|
userRating: claim.userRating
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
HttpException,
|
HttpException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
|
BadRequestException,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Put,
|
Put,
|
||||||
@@ -12,17 +14,23 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
import { extname } from "path";
|
import { extname } from "path";
|
||||||
|
import { Types } from "mongoose";
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
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 { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-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 { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
|
||||||
@ApiTags("claim-request-management (v2)")
|
@ApiTags("claim-request-management (v2)")
|
||||||
@Controller("v2/claim-request-management")
|
@Controller("v2/claim-request-management")
|
||||||
@@ -76,7 +85,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get Claim Details (V2)",
|
summary: "Get Claim Details (V2)",
|
||||||
description:
|
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({
|
@ApiResponse({
|
||||||
status: 200,
|
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")
|
@Post("create-from-blame/:blameRequestId")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "blameRequestId",
|
name: "blameRequestId",
|
||||||
@@ -250,6 +344,66 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
/**
|
/**
|
||||||
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
* 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")
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||||
@@ -284,41 +438,35 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
type: SelectOuterPartsV2Dto,
|
type: SelectOuterPartsV2Dto,
|
||||||
description: "Array of selected damaged outer parts",
|
description:
|
||||||
|
"Selected vehicle type + selected outer part IDs from catalog",
|
||||||
examples: {
|
examples: {
|
||||||
example1: {
|
example1: {
|
||||||
summary: "Minor front damage",
|
summary: "Sedan - minor front damage",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: ["hood", "front_bumper", "front_right_fender"],
|
carType: "sedan",
|
||||||
|
selectedPartIds: [19, 21, 16],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example2: {
|
example2: {
|
||||||
summary: "Side impact damage",
|
summary: "SUV - left side impact",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: [
|
carType: "suv",
|
||||||
"front_left_door",
|
selectedPartIds: [102, 103, 104, 107],
|
||||||
"rear_left_door",
|
|
||||||
"front_left_fender",
|
|
||||||
"rear_left_fender",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example3: {
|
example3: {
|
||||||
summary: "Rear-end collision",
|
summary: "Hatchback - rear-end collision",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
|
carType: "hatchback",
|
||||||
|
selectedPartIds: [225, 226, 210],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
example4: {
|
example4: {
|
||||||
summary: "Multiple damage areas",
|
summary: "Pickup - two sides + roof",
|
||||||
value: {
|
value: {
|
||||||
selectedParts: [
|
carType: "pickup",
|
||||||
"hood",
|
selectedPartIds: [319, 312, 330],
|
||||||
"front_bumper",
|
|
||||||
"front_right_door",
|
|
||||||
"rear_bumper",
|
|
||||||
"trunk",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
* DTO for required document item
|
||||||
@@ -93,6 +94,12 @@ export class DamagedPartItem {
|
|||||||
example: false,
|
example: false,
|
||||||
})
|
})
|
||||||
captured: boolean;
|
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' })
|
@ApiPropertyOptional({ description: 'Blame request number' })
|
||||||
blameRequestNo?: string;
|
blameRequestNo?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Owner info' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
|
||||||
|
})
|
||||||
owner?: {
|
owner?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
clientId?: string;
|
||||||
|
userClientKey?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
@@ -84,6 +88,15 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
})
|
})
|
||||||
expertResend?: ExpertResendDetailsV2Dto;
|
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)' })
|
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||||
userRating?: {
|
userRating?: {
|
||||||
progressSpeed: number;
|
progressSpeed: number;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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
|
* Enum for valid outer car parts that can be damaged
|
||||||
@@ -43,7 +47,7 @@ export class SelectOuterPartsV2Dto {
|
|||||||
minItems: 1,
|
minItems: 1,
|
||||||
maxItems: 13,
|
maxItems: 13,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
|
@IsOptional()
|
||||||
@IsArray({ message: 'selectedParts must be an array' })
|
@IsArray({ message: 'selectedParts must be an array' })
|
||||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||||
@@ -51,7 +55,29 @@ export class SelectOuterPartsV2Dto {
|
|||||||
each: true,
|
each: true,
|
||||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
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[];
|
selectedParts: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Selected part IDs',
|
||||||
|
example: [9, 7, 11],
|
||||||
|
type: [Number],
|
||||||
|
})
|
||||||
|
selectedPartIds: number[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Current workflow step',
|
description: 'Current workflow step',
|
||||||
example: 'SELECT_OUTER_PARTS',
|
example: 'SELECT_OUTER_PARTS',
|
||||||
@@ -94,3 +127,30 @@ export class SelectOuterPartsV2ResponseDto {
|
|||||||
})
|
})
|
||||||
message: string;
|
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 { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
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 })
|
@Schema({ _id: false })
|
||||||
export class ClaimDamageSelection {
|
export class ClaimDamageSelection {
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +24,10 @@ export class ClaimDamageSelection {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
selectedParts?: string[];
|
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
|
* V2: Array of selected other (non-body) damaged parts
|
||||||
* Examples: ['engine', 'suspension', 'headlight']
|
* Examples: ['engine', 'suspension', 'headlight']
|
||||||
|
|||||||
@@ -76,6 +76,25 @@ export class ClaimUserComment {
|
|||||||
export const ClaimUserCommentSchema =
|
export const ClaimUserCommentSchema =
|
||||||
SchemaFactory.createForClass(ClaimUserComment);
|
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 })
|
@Schema({ _id: false })
|
||||||
export class ClaimExpertReply {
|
export class ClaimExpertReply {
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
@@ -205,6 +224,9 @@ export class ClaimFileRating {
|
|||||||
|
|
||||||
@Prop({ type: Number, min: 0, max: 5 })
|
@Prop({ type: Number, min: 0, max: 5 })
|
||||||
guiltyVehicleIdentification?: number;
|
guiltyVehicleIdentification?: number;
|
||||||
|
|
||||||
|
@Prop({ type: Number, min: 0, max: 5 })
|
||||||
|
botRating?: number;
|
||||||
}
|
}
|
||||||
export const ClaimFileRatingSchema =
|
export const ClaimFileRatingSchema =
|
||||||
SchemaFactory.createForClass(ClaimFileRating);
|
SchemaFactory.createForClass(ClaimFileRating);
|
||||||
@@ -251,6 +273,9 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimFileRatingSchema })
|
@Prop({ type: ClaimFileRatingSchema })
|
||||||
rating?: ClaimFileRating;
|
rating?: ClaimFileRating;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||||
|
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
visitLocation?: string;
|
visitLocation?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
import {
|
import {
|
||||||
ExpertProfileSnapshot,
|
ExpertProfileSnapshot,
|
||||||
ExpertProfileSnapshotSchema,
|
ExpertProfileSnapshotSchema,
|
||||||
@@ -23,6 +24,22 @@ export class ClaimActorLock {
|
|||||||
}
|
}
|
||||||
export const ClaimActorLockSchema = SchemaFactory.createForClass(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 })
|
@Schema({ _id: false })
|
||||||
export class ClaimWorkflow {
|
export class ClaimWorkflow {
|
||||||
@Prop({ type: String, enum: ClaimWorkflowStep })
|
@Prop({ type: String, enum: ClaimWorkflowStep })
|
||||||
@@ -40,8 +57,15 @@ export class ClaimWorkflow {
|
|||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
|
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
expiredAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: ClaimActorLockSchema })
|
@Prop({ type: ClaimActorLockSchema })
|
||||||
lockedBy?: ClaimActorLock;
|
lockedBy?: ClaimActorLock;
|
||||||
|
|
||||||
|
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||||
|
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||||
}
|
}
|
||||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||||
|
|
||||||
|
|||||||
@@ -7,25 +7,27 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
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 { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
import { ClientDto } from "./dto/create-client.dto";
|
import { ClientDto } from "./dto/create-client.dto";
|
||||||
|
|
||||||
@Controller("client")
|
@Controller("client")
|
||||||
@ApiTags("client-management")
|
@ApiTags("client-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(GlobalGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.ADMIN)
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
constructor(private readonly clientService: ClientService) {}
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(GlobalGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
async addClient(@Body() client: ClientDto) {
|
async addClient(@Body() client: ClientDto) {
|
||||||
return await this.clientService.addClient(client);
|
return await this.clientService.addClient(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(GlobalGuard)
|
|
||||||
async getClient(@CurrentUser() user) {
|
async getClient(@CurrentUser() user) {
|
||||||
return await this.clientService.getClients();
|
return await this.clientService.getClients();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,44 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
|
|
||||||
class ClientName {
|
class ClientName {
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
persian: string;
|
persian: string;
|
||||||
|
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
english: string;
|
english: string;
|
||||||
}
|
}
|
||||||
class Property {
|
class Property {
|
||||||
@ApiProperty({})
|
@ApiProperty({})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
smsApiKey: string;
|
smsApiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ClientDto {
|
export class ClientDto {
|
||||||
@ApiProperty({ required: true })
|
@ApiProperty({ required: true })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ClientName)
|
||||||
clientName: ClientName;
|
clientName: ClientName;
|
||||||
|
|
||||||
@ApiProperty({ required: true })
|
@ApiProperty({ required: true })
|
||||||
|
@IsNumber()
|
||||||
clientCode: number;
|
clientCode: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
property: Property;
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => Property)
|
||||||
|
property?: Property;
|
||||||
|
|
||||||
@ApiProperty({ examples: ["legal", "genuine"] })
|
@ApiProperty({ examples: ["legal", "genuine"] })
|
||||||
|
@IsIn(["legal", "genuine"])
|
||||||
useExpertMode: "legal" | "genuine";
|
useExpertMode: "legal" | "genuine";
|
||||||
}
|
}
|
||||||
export class ClientDtoRs {
|
export class ClientDtoRs {
|
||||||
@@ -35,6 +48,9 @@ export class ClientDtoRs {
|
|||||||
useExpertsMode: string;
|
useExpertsMode: string;
|
||||||
constructor(readonly client) {
|
constructor(readonly client) {
|
||||||
this.persian = client.clientName.persian;
|
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[]> {
|
async findAll(insuranceId: string): Promise<BranchModel[]> {
|
||||||
return await this.branchModel.find({
|
return await this.branchModel.find({
|
||||||
clientKey: new Types.ObjectId(insuranceId),
|
clientKey: new Types.ObjectId(insuranceId),
|
||||||
});
|
}, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<BranchModel | null> {
|
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;
|
type: string;
|
||||||
blameStatus: string;
|
blameStatus: string;
|
||||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||||
|
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AllRequestDtoRsV2 {
|
export class AllRequestDtoRsV2 {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiProduces,
|
ApiProduces,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response, Request } from "express";
|
import { Response, Request } from "express";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
@@ -45,30 +46,35 @@ export class ExpertBlameController {
|
|||||||
|
|
||||||
// TODO role guard for expert fix
|
// TODO role guard for expert fix
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||||
return await this.expertBlameService.findAll(actor);
|
return await this.expertBlameService.findAll(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.findOne(id, actor.sub);
|
return await this.expertBlameService.findOne(id, actor.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("lock/:id")
|
@Put("lock/:id")
|
||||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.lockRequest(id, actor);
|
return await this.expertBlameService.lockRequest(id, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("request/accident-fields")
|
@Get("request/accident-fields")
|
||||||
async getAccidentFields() {
|
async getAccidentFields() {
|
||||||
return await this.expertBlameService.getAccidentField();
|
return await this.expertBlameService.getAccidentField();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/submit/:id")
|
@Put("reply/submit/:id")
|
||||||
@ApiBody({ type: SubmitReplyDto })
|
@ApiBody({ type: SubmitReplyDto })
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
@@ -95,6 +101,7 @@ export class ExpertBlameController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/resend/first/:id")
|
@Put("reply/resend/first/:id")
|
||||||
@ApiBody({ type: ResendFirstPartyDto })
|
@ApiBody({ type: ResendFirstPartyDto })
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
@@ -107,6 +114,7 @@ export class ExpertBlameController {
|
|||||||
return this.handleResendRequest(id, body, actor.sub, req);
|
return this.handleResendRequest(id, body, actor.sub, req);
|
||||||
}
|
}
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/resend/second/:id")
|
@Put("reply/resend/second/:id")
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
@ApiBody({ type: ResendSecondPartyDto })
|
@ApiBody({ type: ResendSecondPartyDto })
|
||||||
@@ -120,6 +128,7 @@ export class ExpertBlameController {
|
|||||||
return this.handleResendRequest(id, body, actor.sub, req);
|
return this.handleResendRequest(id, body, actor.sub, req);
|
||||||
}
|
}
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("stream/:requestId")
|
@Get("stream/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
async streamVideo(@Param("requestId") requestId: string) {
|
async streamVideo(@Param("requestId") requestId: string) {
|
||||||
@@ -127,6 +136,7 @@ export class ExpertBlameController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseInterceptors(LoggingInterceptor)
|
@UseInterceptors(LoggingInterceptor)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("voice/:requestId/:voiceId")
|
@Get("voice/:requestId/:voiceId")
|
||||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@@ -149,6 +159,7 @@ export class ExpertBlameController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||||
return await this.expertBlameService.inPersonVisit(requestId, 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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.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 { buildFileLink } from "src/helpers/urlCreator";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { ResendRequestDto } from "./dto/resend.dto";
|
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 { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
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 {
|
interface CheckedRequestEntry {
|
||||||
CheckedRequest?: {
|
CheckedRequest?: {
|
||||||
@@ -87,6 +96,7 @@ export class ExpertBlameService {
|
|||||||
private readonly userSignDbService: UserSignDbService,
|
private readonly userSignDbService: UserSignDbService,
|
||||||
private readonly requestManagementService: RequestManagementService,
|
private readonly requestManagementService: RequestManagementService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/** Load immutable profile fields from `expert` for the acting field expert. */
|
/** Load immutable profile fields from `expert` for the acting field expert. */
|
||||||
@@ -304,15 +314,7 @@ export class ExpertBlameService {
|
|||||||
for (const doc of visibleCases) {
|
for (const doc of visibleCases) {
|
||||||
const w = doc.workflow as Record<string, unknown> | undefined;
|
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||||
if (!w?.locked) continue;
|
if (!w?.locked) continue;
|
||||||
const la = w.lockedAt;
|
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
|
||||||
if (!la) {
|
|
||||||
staleIds.add(String(doc._id));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Date.now() >=
|
|
||||||
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
|
|
||||||
) {
|
|
||||||
staleIds.add(String(doc._id));
|
staleIds.add(String(doc._id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,6 +327,7 @@ export class ExpertBlameService {
|
|||||||
if (w) {
|
if (w) {
|
||||||
w.locked = false;
|
w.locked = false;
|
||||||
delete w.lockedAt;
|
delete w.lockedAt;
|
||||||
|
delete w.expiredAt;
|
||||||
delete w.lockedBy;
|
delete w.lockedBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +355,15 @@ export class ExpertBlameService {
|
|||||||
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
|
||||||
const parties = (doc.parties ?? []) as Array<{
|
const parties = (doc.parties ?? []) as Array<{
|
||||||
role?: string;
|
role?: string;
|
||||||
|
vehicle?: {
|
||||||
|
name?: string;
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: {
|
||||||
|
MapTypNam?: string;
|
||||||
|
CarName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
statement?: {
|
statement?: {
|
||||||
admitsGuilt?: boolean;
|
admitsGuilt?: boolean;
|
||||||
claimsDamage?: boolean;
|
claimsDamage?: boolean;
|
||||||
@@ -381,9 +393,43 @@ export class ExpertBlameService {
|
|||||||
firstParty: statementToFormKey(firstParty?.statement) ?? "",
|
firstParty: statementToFormKey(firstParty?.statement) ?? "",
|
||||||
secondParty: statementToFormKey(secondParty?.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}.
|
* 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.
|
* 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 lockedAt = request.workflow.lockedAt;
|
||||||
|
const expiredAt = request.workflow.expiredAt;
|
||||||
|
if (
|
||||||
|
expiredAt &&
|
||||||
|
Date.now() < new Date(expiredAt as Date).getTime()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
lockedAt &&
|
lockedAt &&
|
||||||
Date.now() <
|
Date.now() <
|
||||||
@@ -410,7 +463,9 @@ export class ExpertBlameService {
|
|||||||
_id: new Types.ObjectId(requestId),
|
_id: new Types.ObjectId(requestId),
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
};
|
};
|
||||||
if (lockedAt) {
|
if (expiredAt) {
|
||||||
|
filter["workflow.expiredAt"] = expiredAt;
|
||||||
|
} else if (lockedAt) {
|
||||||
filter["workflow.lockedAt"] = lockedAt;
|
filter["workflow.lockedAt"] = lockedAt;
|
||||||
} else if (lockedById) {
|
} else if (lockedById) {
|
||||||
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
||||||
@@ -422,7 +477,11 @@ export class ExpertBlameService {
|
|||||||
filter as any,
|
filter as any,
|
||||||
{
|
{
|
||||||
$set: { "workflow.locked": false },
|
$set: { "workflow.locked": false },
|
||||||
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
|
$unset: {
|
||||||
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
|
"workflow.lockedBy": "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ new: false },
|
{ new: false },
|
||||||
);
|
);
|
||||||
@@ -432,21 +491,22 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!request.expert?.decision && lockedById) {
|
if (!request.expert?.decision && lockedById) {
|
||||||
const rid = new Types.ObjectId(requestId).toString();
|
|
||||||
try {
|
try {
|
||||||
await this.expertDbService.findOneAndUpdate(
|
await this.recordBlameExpertActivity({
|
||||||
{ _id: new Types.ObjectId(String(lockedById)) },
|
expertId: String(lockedById),
|
||||||
{
|
requestId: String(requestId),
|
||||||
$inc: { "requestStats.totalChecked": -1 },
|
eventType: ExpertFileActivityType.UNCHECKED,
|
||||||
$pull: { countedRequests: rid } as any,
|
tenantId:
|
||||||
},
|
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
||||||
);
|
String(request.parties?.[1]?.person?.clientId ?? ""),
|
||||||
|
idempotencyKey: `blame:${requestId}:unchecked:expired:${lockedById}`,
|
||||||
|
});
|
||||||
this.logger.warn(
|
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) {
|
} catch (e) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Expert stat rollback failed after blame lock expiry ${requestId}`,
|
`Expert activity emit failed after blame lock expiry ${requestId}`,
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -471,13 +531,15 @@ export class ExpertBlameService {
|
|||||||
if (shouldDecrementChecked) {
|
if (shouldDecrementChecked) {
|
||||||
updateExp.blameStatus = ReqBlameStatus.UnChecked;
|
updateExp.blameStatus = ReqBlameStatus.UnChecked;
|
||||||
|
|
||||||
await this.expertDbService.findOneAndUpdate(
|
await this.recordBlameExpertActivity({
|
||||||
{ _id: new Types.ObjectId(r.actorLocked.actorId) },
|
expertId: String(r.actorLocked.actorId),
|
||||||
{
|
requestId: String(r._id),
|
||||||
$inc: { "requestStats.totalChecked": -1 },
|
eventType: ExpertFileActivityType.UNCHECKED,
|
||||||
$pull: { countedRequests: r._id.toString() },
|
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(
|
this.logger.warn(
|
||||||
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
|
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
|
||||||
@@ -527,13 +589,15 @@ export class ExpertBlameService {
|
|||||||
if (shouldRollbackStats) {
|
if (shouldRollbackStats) {
|
||||||
update.blameStatus = ReqBlameStatus.UnChecked;
|
update.blameStatus = ReqBlameStatus.UnChecked;
|
||||||
|
|
||||||
await this.expertDbService.findOneAndUpdate(
|
await this.recordBlameExpertActivity({
|
||||||
{ _id: new Types.ObjectId(current.actorLocked.actorId) },
|
expertId: String(current.actorLocked.actorId),
|
||||||
{
|
requestId: String(current._id),
|
||||||
$inc: { "requestStats.totalChecked": -1 },
|
eventType: ExpertFileActivityType.UNCHECKED,
|
||||||
$pull: { countedRequests: current._id.toString() },
|
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(
|
this.logger.warn(
|
||||||
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
|
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
|
||||||
@@ -688,6 +752,7 @@ export class ExpertBlameService {
|
|||||||
try {
|
try {
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new NotFoundException("Request not found");
|
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 decision = (doc.expert as any)?.decision;
|
||||||
const decidedByExpertId = decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decidedByExpertId;
|
||||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||||
@@ -851,8 +926,16 @@ export class ExpertBlameService {
|
|||||||
throw new BadRequestException("Request already locked or invalid status");
|
throw new BadRequestException("Request already locked or invalid status");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update expert stats atomically (use findOneAndUpdate with conditions)
|
// Record expert check activity
|
||||||
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
|
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);
|
this.scheduleUnlock(updateResult);
|
||||||
return { _id: requestId, lock: true };
|
return { _id: requestId, lock: true };
|
||||||
@@ -897,14 +980,11 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if locked and not expired
|
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
|
||||||
if (request.workflow?.locked) {
|
if (
|
||||||
const lockedAt = request.workflow.lockedAt;
|
request.workflow?.locked &&
|
||||||
if (lockedAt) {
|
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
|
||||||
const lockExpiryTime =
|
) {
|
||||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
|
||||||
if (Date.now() < lockExpiryTime) {
|
|
||||||
// Lock is still valid
|
|
||||||
const lockedByActorId = String(
|
const lockedByActorId = String(
|
||||||
request.workflow.lockedBy?.actorId ?? "",
|
request.workflow.lockedBy?.actorId ?? "",
|
||||||
);
|
);
|
||||||
@@ -912,15 +992,11 @@ export class ExpertBlameService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You have already locked this request",
|
"You have already locked this request",
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Request is currently locked by another expert",
|
"Request is currently locked by another expert",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Lock expired, allow re-locking (continue below)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||||
|
|
||||||
@@ -931,6 +1007,9 @@ export class ExpertBlameService {
|
|||||||
$set: {
|
$set: {
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
|
"workflow.expiredAt": new Date(
|
||||||
|
now.getTime() + this.blameV2LockTtlMs,
|
||||||
|
),
|
||||||
"workflow.lockedBy": {
|
"workflow.lockedBy": {
|
||||||
actorId: new Types.ObjectId(actorDetail.sub),
|
actorId: new Types.ObjectId(actorDetail.sub),
|
||||||
actorName: actorDetail.fullName || "Unknown Expert",
|
actorName: actorDetail.fullName || "Unknown Expert",
|
||||||
@@ -945,8 +1024,15 @@ export class ExpertBlameService {
|
|||||||
throw new InternalServerErrorException("Failed to lock the request");
|
throw new InternalServerErrorException("Failed to lock the request");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update expert stats (reusing existing helper)
|
await this.recordBlameExpertActivity({
|
||||||
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
|
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) {
|
if (request.type === BlameRequestType.THIRD_PARTY) {
|
||||||
const phones = (request.parties || [])
|
const phones = (request.parties || [])
|
||||||
@@ -958,6 +1044,7 @@ export class ExpertBlameService {
|
|||||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
{
|
{
|
||||||
receptor: phone,
|
receptor: phone,
|
||||||
|
fileKind: "blame",
|
||||||
publicId: request.publicId,
|
publicId: request.publicId,
|
||||||
expertLastName,
|
expertLastName,
|
||||||
},
|
},
|
||||||
@@ -1064,7 +1151,15 @@ export class ExpertBlameService {
|
|||||||
`partyId ${uid} is not a party userId on this blame request.`,
|
`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))) {
|
if (!allowedItems.has(String(item))) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
|
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
|
||||||
@@ -1078,9 +1173,12 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
const partyResendRequests = resendDto.parties.map((party) => {
|
const partyResendRequests = resendDto.parties.map((party) => {
|
||||||
const uid = String(party.partyId);
|
const uid = String(party.partyId);
|
||||||
|
const normalizedItems = normalizeResendRequestedItemsList(
|
||||||
|
party.requestedItems as string[],
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
partyId: new Types.ObjectId(uid),
|
partyId: new Types.ObjectId(uid),
|
||||||
requestedItems: party.requestedItems,
|
requestedItems: normalizedItems,
|
||||||
description: party.description || "",
|
description: party.description || "",
|
||||||
requestedAt: now,
|
requestedAt: now,
|
||||||
completed: false,
|
completed: false,
|
||||||
@@ -1103,6 +1201,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"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(
|
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||||
requestId,
|
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 {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
@@ -1262,6 +1413,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"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 {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
status: CaseStatus.WAITING_FOR_SIGNATURES,
|
status: CaseStatus.WAITING_FOR_SIGNATURES,
|
||||||
@@ -1383,6 +1569,7 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.expiredAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1416,65 +1603,32 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateDamageExpertStats(
|
private async recordBlameExpertActivity(args: {
|
||||||
expertId: string,
|
expertId: string;
|
||||||
requestId: string,
|
requestId: string;
|
||||||
type: "checked" | "handled",
|
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
|
||||||
) {
|
) {
|
||||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
|
||||||
console.warn("Invalid expertId, requestId, or type");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await this.expertFileActivityDbService.recordEvent({
|
||||||
const expert = await this.expertDbService.findOne({
|
expertId: args.expertId,
|
||||||
_id: new Types.ObjectId(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) {
|
async replyRequest(requestId: string, reply: SubmitReplyDto, userId: string) {
|
||||||
@@ -1757,11 +1911,15 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.expertDbService.updateStats(
|
await this.recordBlameExpertActivity({
|
||||||
actorDetail.sub,
|
expertId: String(actorDetail.sub),
|
||||||
"handled",
|
requestId: String(requestId),
|
||||||
requestId,
|
eventType: ExpertFileActivityType.HANDLED,
|
||||||
);
|
tenantId:
|
||||||
|
String(request.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
|
||||||
|
String(request.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
|
||||||
|
idempotencyKey: `blame:${requestId}:handled:inperson:${actorDetail.sub}`,
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
lockedAt: string;
|
lockedAt: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -98,6 +99,16 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
damageExpertReplyFinal?: unknown;
|
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({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
|
'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 })
|
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||||
lockedBy?: {
|
lockedBy?: {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
expiredAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: [String],
|
type: [String],
|
||||||
enum: ClaimRequiredDocumentType,
|
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()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
|
|||||||
export class ExpertClaimController {
|
export class ExpertClaimController {
|
||||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get()
|
@Get()
|
||||||
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
||||||
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("/:id")
|
@Get("/:id")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
@@ -64,12 +67,14 @@ export class ExpertClaimController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("imageRequired/:id")
|
@Get("imageRequired/:id")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
||||||
return await this.expertClaimService.imageRequired(id, currentUser);
|
return await this.expertClaimService.imageRequired(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("required-documents/:id")
|
@Get("required-documents/:id")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async getRequiredDocuments(
|
async getRequiredDocuments(
|
||||||
@@ -79,11 +84,13 @@ export class ExpertClaimController {
|
|||||||
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("lock/:id")
|
@Put("lock/:id")
|
||||||
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||||
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("reply/submit/:id")
|
@Put("reply/submit/:id")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async submitReplyRequest(
|
async submitReplyRequest(
|
||||||
@@ -98,6 +105,7 @@ export class ExpertClaimController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("resend/submit/:id")
|
@Put("resend/submit/:id")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async submitResend(
|
async submitResend(
|
||||||
@@ -117,6 +125,7 @@ export class ExpertClaimController {
|
|||||||
enum: ["car-capture", "accident"],
|
enum: ["car-capture", "accident"],
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("stream/:id/video")
|
@Get("stream/:id/video")
|
||||||
@Header("Accept-Ranges", "bytes")
|
@Header("Accept-Ranges", "bytes")
|
||||||
@Header("Content-Type", "video/mp4")
|
@Header("Content-Type", "video/mp4")
|
||||||
@@ -134,6 +143,7 @@ export class ExpertClaimController {
|
|||||||
enum: ["car-capture", "accident"],
|
enum: ["car-capture", "accident"],
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("link/:id/video")
|
@Get("link/:id/video")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async getClaimVideoLink(
|
async getClaimVideoLink(
|
||||||
@@ -143,12 +153,14 @@ export class ExpertClaimController {
|
|||||||
return this.expertClaimService.getVideoLink(id, query);
|
return this.expertClaimService.getVideoLink(id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("stream/:id/voice")
|
@Get("stream/:id/voice")
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
async getClaimVoiceLink(@Param("id") id: string) {
|
async getClaimVoiceLink(@Param("id") id: string) {
|
||||||
return await this.expertClaimService.getVoiceLink(id);
|
return await this.expertClaimService.getVoiceLink(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch("validate-factors/:claimId")
|
@Patch("validate-factors/:claimId")
|
||||||
@ApiParam({ name: "claimId" })
|
@ApiParam({ name: "claimId" })
|
||||||
@ApiBody({ type: FactorValidationDto })
|
@ApiBody({ type: FactorValidationDto })
|
||||||
@@ -166,11 +178,13 @@ export class ExpertClaimController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ApiParam({ name: "id" })
|
@ApiParam({ name: "id" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Patch(":id/visit")
|
@Patch(":id/visit")
|
||||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||||
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
@ApiParam({ name: "insuranceId" })
|
@ApiParam({ name: "insuranceId" })
|
||||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
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 { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
|
||||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||||
import { SandHubModule } from "src/sand-hub/sand-hub.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 { UsersModule } from "src/users/users.module";
|
||||||
import { ExpertClaimController } from "./expert-claim.controller";
|
import { ExpertClaimController } from "./expert-claim.controller";
|
||||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||||
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
|||||||
ExpertBlameModule,
|
ExpertBlameModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
|
SmsOrchestrationModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
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 {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
|
|||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||||
|
import {
|
||||||
|
CreateBlameExpertByInsurerDto,
|
||||||
|
CreateClaimExpertByInsurerDto,
|
||||||
|
} from "./dto/create-insurer-expert.dto";
|
||||||
|
|
||||||
@Controller("expert-insurer")
|
@Controller("expert-insurer")
|
||||||
@ApiTags("expert-insurer-panel")
|
@ApiTags("expert-insurer-panel")
|
||||||
@@ -35,16 +40,59 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
|||||||
export class ExpertInsurerController {
|
export class ExpertInsurerController {
|
||||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
||||||
|
|
||||||
@Get("files")
|
@Get("branches")
|
||||||
async getAllFiles(@CurrentUser() insurer) {
|
async getInsuranceBranches(@CurrentUser() insurer) {
|
||||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
if (!insurer) {
|
||||||
|
throw new UnauthorizedException("Could not identify the current user.");
|
||||||
|
}
|
||||||
|
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||||
insurer.clientKey,
|
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: "page", type: Number })
|
||||||
@ApiQuery({ name: "response_count", type: Number })
|
@ApiQuery({ name: "response_count", type: Number })
|
||||||
@Get("list")
|
@Get("experts/list")
|
||||||
async getAllExperts(
|
async getAllExperts(
|
||||||
@Query("page") page: number,
|
@Query("page") page: number,
|
||||||
@Query("response_count") count: 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) {
|
async getTopExperts(@CurrentUser() actor) {
|
||||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("top-files")
|
@Get("files")
|
||||||
async getTopFiles(@CurrentUser() insurer) {
|
async getAllFiles(@CurrentUser() insurer) {
|
||||||
return await this.expertInsurerService.getTopFilesForClient(
|
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("statistics")
|
@Get("files/:publicId")
|
||||||
async getExpertStatistics(@CurrentUser() actor) {
|
@ApiParam({ name: "publicId" })
|
||||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
async getFileDetailsByPublicId(
|
||||||
}
|
|
||||||
|
|
||||||
@ApiParam({ name: "expertId" })
|
|
||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
|
||||||
@Get("/:expertId")
|
|
||||||
async requestDetail(
|
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Param("expertId") id: string,
|
@Param("publicId") publicId: string,
|
||||||
@Query("role") role: "claim" | "blame",
|
|
||||||
) {
|
) {
|
||||||
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
|
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
||||||
throw new BadRequestException("Invalid expert ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["claim", "blame"].includes(role)) {
|
|
||||||
throw new BadRequestException("Invalid role");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(
|
|
||||||
id,
|
|
||||||
role,
|
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
|
publicId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@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: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -135,7 +173,7 @@ export class ExpertInsurerController {
|
|||||||
minimum: 0,
|
minimum: 0,
|
||||||
maximum: 5,
|
maximum: 5,
|
||||||
example: 4,
|
example: 4,
|
||||||
description: "برای امتیاز دادن به بات",
|
description: "برای امتیاز دادن به عملکرد بات",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
@@ -154,48 +192,70 @@ export class ExpertInsurerController {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "publicId" })
|
||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
@Put("files/:publicId/rating")
|
||||||
@Put("/:requestId/rating")
|
async rateExpertsByPublicId(
|
||||||
async rateExperts(
|
|
||||||
@CurrentUser() insurer,
|
@CurrentUser() insurer,
|
||||||
@Param("requestId") requestId: string,
|
@Param("publicId") publicId: string,
|
||||||
@Body() rating: FileRating,
|
@Body() rating: FileRating,
|
||||||
@Query("role") role: "claim" | "blame",
|
|
||||||
) {
|
) {
|
||||||
if (!["claim", "blame"].includes(role)) {
|
return await this.expertInsurerService.rateExpertByPublicId(
|
||||||
throw new BadRequestException("Invalid role");
|
publicId,
|
||||||
}
|
|
||||||
|
|
||||||
return await this.expertInsurerService.rateExpertOnFile(
|
|
||||||
requestId,
|
|
||||||
rating,
|
rating,
|
||||||
role,
|
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("branches/:insuranceId")
|
@Get("top-files")
|
||||||
@ApiParam({ name: "insuranceId" })
|
async getTopFiles(@CurrentUser() insurer) {
|
||||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
return await this.expertInsurerService.getTopFilesForClient(
|
||||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
insurer.clientKey,
|
||||||
insuranceId,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("branches")
|
@Get("statistics")
|
||||||
@ApiBody({ type: CreateBranchDto })
|
async getExpertStatistics(@CurrentUser() actor) {
|
||||||
async addBranch(
|
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||||
@CurrentUser() insurer,
|
}
|
||||||
@Body() createBranchDto: CreateBranchDto,
|
|
||||||
|
@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) {
|
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||||
throw new UnauthorizedException("Could not identify the current user.");
|
actor,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.expertInsurerService.addBranch(
|
@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.getAllFilesForInsurerExpert(
|
||||||
|
id,
|
||||||
insurer.clientKey,
|
insurer.clientKey,
|
||||||
createBranchDto,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import {
|
|||||||
BranchModel,
|
BranchModel,
|
||||||
BranchSchema,
|
BranchSchema,
|
||||||
} from "src/client/entities/schema/branch.schema";
|
} from "src/client/entities/schema/branch.schema";
|
||||||
|
import { HashModule } from "src/utils/hash/hash.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
HashModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
MongooseModule.forFeature([
|
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 { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
|
import {
|
||||||
|
CreateBlameExpertByInsurerDto,
|
||||||
|
CreateClaimExpertByInsurerDto,
|
||||||
|
CreateInsurerExpertDto,
|
||||||
|
} from "./dto/create-insurer-expert.dto";
|
||||||
import {
|
import {
|
||||||
blameCaseTouchesClient,
|
blameCaseTouchesClient,
|
||||||
claimCaseTouchesClient,
|
claimCaseTouchesClient,
|
||||||
@@ -16,17 +21,25 @@ import {
|
|||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
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 { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
|
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()
|
@Injectable()
|
||||||
export class ExpertInsurerService {
|
export class ExpertInsurerService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
|
private readonly hashService: HashService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private getClientId(actorOrId: any): Types.ObjectId {
|
private getClientId(actorOrId: any): Types.ObjectId {
|
||||||
@@ -37,6 +50,53 @@ export class ExpertInsurerService {
|
|||||||
return new Types.ObjectId(raw);
|
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 {
|
private parseObjectId(value: string, label: string): Types.ObjectId {
|
||||||
if (!Types.ObjectId.isValid(value)) {
|
if (!Types.ObjectId.isValid(value)) {
|
||||||
throw new BadRequestException(`Invalid ${label}`);
|
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 {
|
private getCombinedFileScore(file: any): number | null {
|
||||||
const insurerRating = file?.rating;
|
const insurerRating = file?.rating;
|
||||||
const userRating = file?.userRating;
|
const userRating = file?.userRating;
|
||||||
const insurerValues = insurerRating
|
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
|
const userValues = userRating
|
||||||
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
||||||
@@ -115,11 +261,6 @@ export class ExpertInsurerService {
|
|||||||
.filter((f) =>
|
.filter((f) =>
|
||||||
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
|
(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));
|
.map((f) => this.normalizeBlameCase(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,20 +268,26 @@ export class ExpertInsurerService {
|
|||||||
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
const idStr = String(clientObjectId);
|
const idStr = String(clientObjectId);
|
||||||
return all
|
return all
|
||||||
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
|
.filter((f) => claimCaseTouchesClient(f, idStr))
|
||||||
.map((f) => this.normalizeClaimCase(f));
|
.map((f) => this.normalizeClaimCase(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
||||||
const clientObjectId = this.getClientId(actor);
|
const clientObjectId = this.getClientId(actor);
|
||||||
|
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
|
||||||
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||||||
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
this.getClientBlameFiles(clientObjectId),
|
this.getClientBlameFiles(clientObjectId),
|
||||||
this.getClientClaimFiles(clientObjectId),
|
this.getClientClaimFiles(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allExpertsRaw = [...experts, ...damageExperts];
|
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 expertTotalRatingsMap: Record<string, number[]> = {};
|
||||||
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
||||||
|
|
||||||
@@ -155,6 +302,7 @@ export class ExpertInsurerService {
|
|||||||
if (!rating || typeof rating !== "object") return;
|
if (!rating || typeof rating !== "object") return;
|
||||||
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
||||||
for (const [category, value] of Object.entries(rating)) {
|
for (const [category, value] of Object.entries(rating)) {
|
||||||
|
if (category === "botRating") continue;
|
||||||
if (typeof value === "number" && !isNaN(value)) {
|
if (typeof value === "number" && !isNaN(value)) {
|
||||||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
if (!expertRatingsByCategoryMap[expertId][category]) {
|
||||||
expertRatingsByCategoryMap[expertId][category] = [];
|
expertRatingsByCategoryMap[expertId][category] = [];
|
||||||
@@ -171,7 +319,7 @@ export class ExpertInsurerService {
|
|||||||
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allExperts = allExpertsRaw.map((expert) => {
|
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
|
||||||
const expertIdStr = expert._id.toString();
|
const expertIdStr = expert._id.toString();
|
||||||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
||||||
const overallAverageRating = totalRatings.length
|
const overallAverageRating = totalRatings.length
|
||||||
@@ -192,13 +340,22 @@ export class ExpertInsurerService {
|
|||||||
_id: expert._id,
|
_id: expert._id,
|
||||||
fullName: `${expert.firstName} ${expert.lastName}`,
|
fullName: `${expert.firstName} ${expert.lastName}`,
|
||||||
role: expert.role,
|
role: expert.role,
|
||||||
|
expertKind,
|
||||||
type: expert.userType,
|
type: expert.userType,
|
||||||
requestStats: expert.requestStats,
|
requestStats: expertActivityStatsMap[expertIdStr] ?? {
|
||||||
|
totalHandled: 0,
|
||||||
|
totalChecked: 0,
|
||||||
|
},
|
||||||
createdAt: expert.createdAt,
|
createdAt: expert.createdAt,
|
||||||
overallAverageRating,
|
overallAverageRating,
|
||||||
averageRatingsByCategory,
|
averageRatingsByCategory,
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const allExperts = [
|
||||||
|
...experts.map((e) => mapExpertRow(e, "blame")),
|
||||||
|
...damageExperts.map((e) => mapExpertRow(e, "claim")),
|
||||||
|
];
|
||||||
|
|
||||||
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||||
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
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
|
* Top experts per roster: blame rows come from the `expert` collection (files they
|
||||||
* combined insurer + user ratings on their handled files.
|
* 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 result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
|
||||||
const experts = result?.experts || [];
|
const rows = result?.experts || [];
|
||||||
|
|
||||||
const sorted = [...experts].sort((a, b) => {
|
const byRatingDesc = (a: any, b: any) =>
|
||||||
const ar = a.overallAverageRating ?? 0;
|
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
|
||||||
const br = b.overallAverageRating ?? 0;
|
|
||||||
return br - ar;
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertExpertBelongsToInsurer(
|
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
|
||||||
expertObjectId: Types.ObjectId,
|
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||||||
insurerClientKey: string,
|
const clientOid = this.getClientId(insurerClientKey);
|
||||||
) {
|
const ckFilter = this.clientKeyScopeFilter(clientOid);
|
||||||
const ck = String(insurerClientKey);
|
|
||||||
const clientOid = new Types.ObjectId(ck);
|
|
||||||
const [experts, damageExperts] = await Promise.all([
|
const [experts, damageExperts] = await Promise.all([
|
||||||
this.expertDbService.findAll({ clientKey: ck }),
|
this.expertDbService.findAll(ckFilter as never),
|
||||||
this.damageExpertDbService.findAll({ clientKey: clientOid }),
|
this.damageExpertDbService.findAll(ckFilter as never),
|
||||||
]);
|
]);
|
||||||
const id = String(expertObjectId);
|
const id = String(expertObjectId);
|
||||||
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
|
const onBlameRoster = experts.some((e) => String(e._id) === id);
|
||||||
if (!ok) {
|
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
|
||||||
|
if (!onBlameRoster && !onClaimRoster) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"This expert is not registered under your insurance company.",
|
"This expert is not registered under your insurance company.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async getAllFilesByExpertAndRole(
|
const clientKeyStr = String(clientOid);
|
||||||
expertId: string,
|
if (onBlameRoster) {
|
||||||
role: "claim" | "blame",
|
|
||||||
insurerClientKey: string,
|
|
||||||
) {
|
|
||||||
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
|
||||||
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
|
|
||||||
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
|
||||||
|
|
||||||
if (role === "claim") {
|
|
||||||
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
|
||||||
return claims
|
|
||||||
.filter(
|
|
||||||
(c) =>
|
|
||||||
claimCaseTouchesClient(c, clientKeyStr) &&
|
|
||||||
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
|
|
||||||
String(expertObjectId),
|
|
||||||
)
|
|
||||||
.map((c) => this.normalizeClaimCase(c));
|
|
||||||
}
|
|
||||||
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||||
return blames
|
return blames
|
||||||
.filter(
|
.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
blameCaseTouchesClient(b, clientKeyStr) &&
|
blameCaseTouchesClient(b, clientKeyStr) &&
|
||||||
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
|
this.blameFieldExpertIdCandidates(b).includes(id),
|
||||||
)
|
)
|
||||||
.map((b) => this.normalizeBlameCase(b));
|
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
|
||||||
|
}
|
||||||
|
if (onClaimRoster) {
|
||||||
|
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
|
return claims
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
claimCaseTouchesClient(c, clientKeyStr) &&
|
||||||
|
this.claimDamageExpertActorId(c) === id,
|
||||||
|
)
|
||||||
|
.map((c) => this.mapClaimFileSummaryForInsurerExpert(c));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async rateExpertOnFile(
|
private validateInsurerFileRating(rating: FileRating) {
|
||||||
requestId: string,
|
const required: (keyof FileRating)[] = [
|
||||||
rating: FileRating,
|
"collisionMethodAccuracy",
|
||||||
role: "claim" | "blame",
|
"evaluationTimeliness",
|
||||||
insurerClientKey: string,
|
"accidentCauseAccuracy",
|
||||||
) {
|
"guiltyVehicleIdentification",
|
||||||
const _id = this.parseObjectId(requestId, "request id");
|
"botRating",
|
||||||
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
];
|
||||||
for (const [key, value] of Object.entries(rating || {})) {
|
for (const key of required) {
|
||||||
|
const value = rating?.[key];
|
||||||
if (typeof value !== "number" || value < 0 || value > 5) {
|
if (typeof value !== "number" || value < 0 || value > 5) {
|
||||||
throw new BadRequestException(`${key} must be a number between 0 and 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 },
|
$set: { "evaluation.rating": rating },
|
||||||
});
|
});
|
||||||
if (!updated) throw new NotFoundException("Claim file not found");
|
if (!updated) throw new NotFoundException("Claim file not found");
|
||||||
return {
|
out.claim = {
|
||||||
message: "Claim expert rated successfully",
|
requestId: String((updated as any)._id),
|
||||||
updatedRating: (updated as any)?.evaluation?.rating || rating,
|
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
|
||||||
requestId: updated._id,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (role === "blame") {
|
|
||||||
const existing = await this.blameRequestDbService.findById(_id);
|
if (blame) {
|
||||||
if (!existing) throw new NotFoundException("Blame file not found");
|
const blameId = this.parseObjectId(String((blame as any)._id), "blame id");
|
||||||
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
|
const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, {
|
||||||
throw new ForbiddenException("This blame case does not belong to your organization.");
|
|
||||||
}
|
|
||||||
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
|
|
||||||
$set: { "expert.rating": rating },
|
$set: { "expert.rating": rating },
|
||||||
});
|
});
|
||||||
if (!updated) throw new NotFoundException("Blame file not found");
|
if (!updated) throw new NotFoundException("Blame file not found");
|
||||||
return {
|
out.blame = {
|
||||||
message: "Blame expert rated successfully",
|
requestId: String((updated as any)._id),
|
||||||
updatedRating: (updated as any)?.expert?.rating || rating,
|
updatedRating: (updated as any)?.expert?.rating ?? rating,
|
||||||
requestId: updated._id,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new BadRequestException("Invalid role");
|
|
||||||
|
return {
|
||||||
|
message: "Rating saved for this file",
|
||||||
|
...out,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveAllFilesOfClient(insurerId: string) {
|
async retrieveAllFilesOfClient(insurerId: string) {
|
||||||
@@ -347,7 +540,245 @@ export class ExpertInsurerService {
|
|||||||
this.getClientBlameFiles(id),
|
this.getClientBlameFiles(id),
|
||||||
this.getClientClaimFiles(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) {
|
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
||||||
@@ -372,6 +803,112 @@ export class ExpertInsurerService {
|
|||||||
return newBranch;
|
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
|
* Get comprehensive statistics for all experts of a client
|
||||||
* Returns:
|
* Returns:
|
||||||
@@ -501,4 +1038,113 @@ export class ExpertInsurerService {
|
|||||||
async retrieveInsuranceBranches(insuranceId: string) {
|
async retrieveInsuranceBranches(insuranceId: string) {
|
||||||
return this.branchDbService.findAll(insuranceId);
|
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 { 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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
|
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||||
|
|
||||||
export function resendRequestHasPayload(r: {
|
export function resendRequestHasPayload(r: {
|
||||||
resendDescription?: string;
|
resendDescription?: string;
|
||||||
@@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: {
|
|||||||
return false;
|
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[] {
|
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
|
||||||
if (!Array.isArray(docs)) return [];
|
if (!Array.isArray(docs)) return [];
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
if (typeof d === "string" && d.trim()) keys.push(d.trim());
|
if (typeof d === "string" && d.trim()) {
|
||||||
else if (d && typeof d === "object" && "key" in (d as object)) {
|
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();
|
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[] {
|
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||||
if (!Array.isArray(parts)) return [];
|
if (!Array.isArray(parts)) return [];
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
@@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend(
|
|||||||
const r = claim?.evaluation?.damageExpertResend;
|
const r = claim?.evaluation?.damageExpertResend;
|
||||||
if (!r || r.fulfilledAt) return false;
|
if (!r || r.fulfilledAt) return false;
|
||||||
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
|
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 {
|
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")
|
.setVersion("1.0.0")
|
||||||
.addServer(process.env.URL)
|
.addServer(process.env.URL)
|
||||||
.addServer(process.env.BASE_URL + "/api")
|
.addServer(process.env.BASE_URL + "/api")
|
||||||
.addServer("http://192.168.20.249:9001")
|
|
||||||
.addServer("http://192.168.20.170:9001")
|
.addServer("http://192.168.20.170:9001")
|
||||||
.addServer("http://localhost:9001")
|
.addServer("http://localhost:9001")
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const docs = SwaggerModule.createDocument(app, config);
|
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);
|
await app.listen(process.env.PORT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ export class ProfileController {
|
|||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
@ApiBody({ type: AddPlateProfileDto })
|
@ApiBody({ type: AddPlateProfileDto })
|
||||||
async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) {
|
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);
|
return await this.plateService.addPlate(user.sub, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,99 @@
|
|||||||
/**
|
/**
|
||||||
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
|
* V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
|
||||||
* plus `all` = total matching documents for the tenant.
|
* `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
|
||||||
*/
|
*/
|
||||||
export class BlameCaseStatusCountReportDtoRs {
|
export class InsurerRequestsSummaryDtoRs {
|
||||||
[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>;
|
|
||||||
claim: Record<string, number>;
|
claim: Record<string, number>;
|
||||||
|
blame: Record<string, number>;
|
||||||
|
unifiedByPublicId: { total: number };
|
||||||
|
|
||||||
constructor(blame: Record<string, number>, claim: Record<string, number>) {
|
constructor(
|
||||||
this.blame = blame;
|
claim: Record<string, number>,
|
||||||
|
blame: Record<string, number>,
|
||||||
|
unifiedTotal: number,
|
||||||
|
) {
|
||||||
this.claim = claim;
|
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 { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
|
|||||||
@ApiTags("reports")
|
@ApiTags("reports")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY)
|
@Roles(RoleEnum.COMPANY)
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private readonly reportsService: ReportsService) {}
|
constructor(private readonly reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get("/report/requests")
|
@Get("report/insurer/requests")
|
||||||
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) {
|
@ApiOperation({
|
||||||
return await this.reportsService.getAllRequestsReportCount(actor, client);
|
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")
|
@Get("report/insurer/per-month-requests")
|
||||||
async getAllRequestsReportPerMonth(
|
@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,
|
@CurrentUser() actor,
|
||||||
@ClientKey() client,
|
@Query("from") from?: string,
|
||||||
|
@Query("to") to?: string,
|
||||||
) {
|
) {
|
||||||
return await this.reportsService.getAllRequestsReportPerMonth(
|
return await this.reportsService.getInsurerRequestsSummaryByDateRange(
|
||||||
actor,
|
actor,
|
||||||
client,
|
from,
|
||||||
|
to,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/report/checkedRequests")
|
@Get("report/insurer/expert-work-log")
|
||||||
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) {
|
@ApiOperation({
|
||||||
return await this.reportsService.getAllCheckedRequestsCount(actor, client);
|
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 { Module } from "@nestjs/common";
|
||||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||||
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { ReportsController } from "./reports.controller";
|
import { ReportsController } from "./reports.controller";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service";
|
|||||||
imports: [
|
imports: [
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
|||||||
@@ -1,197 +1,199 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { 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 {
|
import {
|
||||||
BlameCaseStatusCountReportDtoRs,
|
blameCaseStatusToReportBucket,
|
||||||
ClaimCaseStatusCountReportDtoRs,
|
claimCaseStatusToReportBucket,
|
||||||
CompanyAllRequestsCountReportDtoRs,
|
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";
|
} from "./dto/reports.dto";
|
||||||
|
|
||||||
|
type NormalizedActivity = {
|
||||||
|
expertId: string;
|
||||||
|
fileId: string;
|
||||||
|
eventType: ExpertFileActivityType;
|
||||||
|
occurredAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
|
private readonly expertDbService: ExpertDbService,
|
||||||
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private clientObjectId(client: string | undefined): Types.ObjectId {
|
private parseDateRange(from?: string, to?: string) {
|
||||||
const ck = requireActorClientKey({ clientKey: client });
|
const fromDate = from ? new Date(from) : undefined;
|
||||||
return new Types.ObjectId(ck);
|
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 {
|
private isInDateRange(
|
||||||
const idStr = String(client);
|
value: unknown,
|
||||||
return (r?.parties || []).some(
|
fromDate?: Date,
|
||||||
(p) => String(p?.person?.clientId || "") === idStr,
|
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 {
|
private publicIdKey(doc: Record<string, unknown>): string {
|
||||||
return String(r?.owner?.userClientKey || "") === String(client);
|
return String(doc.publicId ?? doc._id ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
private countBlameByCaseStatus(
|
private buildClaimBucketsForInsurer(
|
||||||
blames: any[],
|
rows: Record<string, unknown>[],
|
||||||
clientId: Types.ObjectId,
|
clientKey: string,
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date,
|
||||||
): Record<string, number> {
|
): Record<string, number> {
|
||||||
const out: Record<string, number> = { all: 0 };
|
const buckets = initialClaimExpertReportBuckets();
|
||||||
for (const s of Object.values(CaseStatus)) {
|
for (const doc of rows) {
|
||||||
out[s] = 0;
|
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) {
|
return buckets;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private countClaimByCaseStatus(
|
private buildBlameBucketsForInsurer(
|
||||||
claims: any[],
|
rows: Record<string, unknown>[],
|
||||||
clientId: Types.ObjectId,
|
clientKey: string,
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date,
|
||||||
): Record<string, number> {
|
): Record<string, number> {
|
||||||
const out: Record<string, number> = { all: 0 };
|
const buckets = initialBlameExpertReportBuckets();
|
||||||
for (const s of Object.values(ClaimCaseStatus)) {
|
for (const doc of rows) {
|
||||||
out[s] = 0;
|
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) {
|
return buckets;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
|
* One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
|
||||||
* Company gets both blame and claim maps; expert / damage_expert get one map.
|
|
||||||
*/
|
*/
|
||||||
async getDateFilteredRequestByStatus(
|
private countUnifiedByPublicId(
|
||||||
role: string,
|
blameRows: Record<string, unknown>[],
|
||||||
client: string | undefined,
|
claimRows: Record<string, unknown>[],
|
||||||
start: Date,
|
clientKey: string,
|
||||||
end: Date,
|
dateFrom?: Date,
|
||||||
) {
|
dateTo?: Date,
|
||||||
const clientId = this.clientObjectId(client);
|
): 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([
|
const [blames, claims] = await Promise.all([
|
||||||
this.blameRequestDbService.find({}, { lean: true }),
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
this.claimCaseDbService.find({}, { lean: true }),
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
]);
|
]);
|
||||||
|
return this.buildInsurerSummary(
|
||||||
const inRange = (r: any) => {
|
blames as Record<string, unknown>[],
|
||||||
const createdAt = new Date(r.createdAt);
|
claims as Record<string, unknown>[],
|
||||||
return createdAt >= start && createdAt <= end;
|
clientKey,
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
|
async getInsurerRequestsSummaryPerMonth(actor: {
|
||||||
requireActorClientKey(actor);
|
clientKey?: string;
|
||||||
const result: any[] = [];
|
}): 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();
|
const now = new Date();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
@@ -209,35 +211,286 @@ export class ReportsService {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
let data: any;
|
const data = this.buildInsurerSummary(
|
||||||
if (actor.role === "company") {
|
blameRows,
|
||||||
data = await this.getDateFilteredRequestByStatus(
|
claimRows,
|
||||||
"company",
|
clientKey,
|
||||||
client,
|
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
data = await this.getDateFilteredRequestByStatus(
|
|
||||||
actor.role,
|
|
||||||
client,
|
|
||||||
monthStart,
|
|
||||||
monthEnd,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.unshift({
|
result.unshift(
|
||||||
stDate: monthStart,
|
new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
|
||||||
enDate: monthEnd,
|
);
|
||||||
faLabel: label,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
|
async getInsurerRequestsSummaryByDateRange(
|
||||||
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
|
actor: { clientKey?: string },
|
||||||
return this.getAllRequestsReportCount(actor, client);
|
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 })
|
@Prop({ type: Date })
|
||||||
lockedAt?: Date;
|
lockedAt?: Date;
|
||||||
|
|
||||||
|
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||||
|
@Prop({ type: Date })
|
||||||
|
expiredAt?: Date;
|
||||||
|
|
||||||
@Prop({ type: ActorLockSchema })
|
@Prop({ type: ActorLockSchema })
|
||||||
lockedBy?: ActorLock;
|
lockedBy?: ActorLock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Get,
|
Get,
|
||||||
Query,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -48,6 +46,7 @@ export class ExpertInitiatedController {
|
|||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("my-files")
|
@Get("my-files")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List my expert-initiated files",
|
summary: "List my expert-initiated files",
|
||||||
@@ -61,6 +60,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("complete-blame-data/:requestId")
|
@Post("complete-blame-data/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit all blame-needed data",
|
summary: "Submit all blame-needed data",
|
||||||
@@ -86,6 +86,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("complete-claim-data/:claimRequestId")
|
@Post("complete-claim-data/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Submit claim-needed data",
|
summary: "Submit claim-needed data",
|
||||||
@@ -111,6 +112,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("create")
|
@Post("create")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Create expert-initiated blame file",
|
summary: "Create expert-initiated blame file",
|
||||||
@@ -138,6 +140,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("complete-form-third-party/:requestId")
|
@Post("complete-form-third-party/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Expert completes all information for IN_PERSON THIRD_PARTY file",
|
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")
|
@Post("complete-form-car-body/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Expert completes all information for IN_PERSON CAR_BODY file",
|
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")
|
@Post("upload-video/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Expert uploads video for expert-initiated file",
|
summary: "Expert uploads video for expert-initiated file",
|
||||||
@@ -252,6 +257,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("upload-voice/:requestId")
|
@Post("upload-voice/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Expert uploads voice for expert-initiated file",
|
summary: "Expert uploads voice for expert-initiated file",
|
||||||
@@ -309,6 +315,7 @@ export class ExpertInitiatedController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("add-accident-fields/:requestId")
|
@Post("add-accident-fields/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Expert adds accident fields to expert-initiated file",
|
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,
|
Req,
|
||||||
Put,
|
Put,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
Patch,
|
|
||||||
Query,
|
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
FileFieldsInterceptor,
|
FileFieldsInterceptor,
|
||||||
@@ -23,8 +21,8 @@ import {
|
|||||||
ApiBody,
|
ApiBody,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
|
||||||
ApiTags,
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { diskStorage } from "multer";
|
import { diskStorage } from "multer";
|
||||||
@@ -44,7 +42,6 @@ import {
|
|||||||
LocationDto,
|
LocationDto,
|
||||||
} from "./dto/create-request-management.dto";
|
} from "./dto/create-request-management.dto";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
import { InPersonVisitDto } from "src/claim-request-management/dto/in-person-visit.dto";
|
|
||||||
|
|
||||||
@Controller("blame-request-management")
|
@Controller("blame-request-management")
|
||||||
@ApiTags("blame-request-management")
|
@ApiTags("blame-request-management")
|
||||||
@@ -56,6 +53,7 @@ export class RequestManagementController {
|
|||||||
private readonly requestManagementService: RequestManagementService,
|
private readonly requestManagementService: RequestManagementService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
async createRequest(
|
async createRequest(
|
||||||
@@ -68,6 +66,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/initial-form/:requestId")
|
@Post("/initial-form/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: InitialFormDto })
|
@ApiBody({ type: InitialFormDto })
|
||||||
@@ -115,6 +114,7 @@ export class RequestManagementController {
|
|||||||
)
|
)
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("upload-video/:requestId")
|
@Post("upload-video/:requestId")
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
async videoUploader(
|
async videoUploader(
|
||||||
@@ -129,6 +129,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/add-plate/:requestId")
|
@Post("/add-plate/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: AddPlateDto })
|
@ApiBody({ type: AddPlateDto })
|
||||||
@@ -146,6 +147,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/carbody-form/:requestId")
|
@Post("/carbody-form/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: CarBodyFormDto })
|
@ApiBody({ type: CarBodyFormDto })
|
||||||
@@ -163,6 +165,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/carbody-secondform/:requestId")
|
@Post("/carbody-secondform/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: CarBodySecondForm })
|
@ApiBody({ type: CarBodySecondForm })
|
||||||
@@ -180,6 +183,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/add-detail-location/:requestId")
|
@Post("/add-detail-location/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: LocationDto })
|
@ApiBody({ type: LocationDto })
|
||||||
@@ -228,6 +232,7 @@ export class RequestManagementController {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("upload-voice/:requestId")
|
@Post("upload-voice/:requestId")
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
async voiceUploader(
|
async voiceUploader(
|
||||||
@@ -242,6 +247,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("/add-detail-description/:requestId")
|
@Post("/add-detail-description/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: DescriptionDto })
|
@ApiBody({ type: DescriptionDto })
|
||||||
@@ -259,6 +265,7 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Post("add-second-party-details/:phoneNumber/:requestId/:frontendRoutes")
|
@Post("add-second-party-details/:phoneNumber/:requestId/:frontendRoutes")
|
||||||
@ApiParam({ name: "phoneNumber" })
|
@ApiParam({ name: "phoneNumber" })
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@@ -280,17 +287,20 @@ export class RequestManagementController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("requests")
|
@Get("requests")
|
||||||
async requests(@CurrentUser() user) {
|
async requests(@CurrentUser() user) {
|
||||||
return await this.requestManagementService.myRequests(user.username);
|
return await this.requestManagementService.myRequests(user.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("creatable-claims")
|
@Get("creatable-claims")
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
async getCreatableClaims(@CurrentUser() user) {
|
async getCreatableClaims(@CurrentUser() user) {
|
||||||
return await this.requestManagementService.getCreatableClaims(user);
|
return await this.requestManagementService.getCreatableClaims(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Get("request/:requestId")
|
@Get("request/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@Roles(RoleEnum.USER)
|
@Roles(RoleEnum.USER)
|
||||||
@@ -329,6 +339,7 @@ export class RequestManagementController {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("request/reply/:requestId")
|
@Put("request/reply/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
@@ -385,6 +396,7 @@ export class RequestManagementController {
|
|||||||
)
|
)
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
@Put("request/resend/:requestId")
|
@Put("request/resend/:requestId")
|
||||||
userResendReply(
|
userResendReply(
|
||||||
@Body("description") description: string,
|
@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 { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
||||||
import {
|
import {
|
||||||
buildResendItemsWithUi,
|
buildResendItemsWithUi,
|
||||||
|
cloneResendUploadedDocuments,
|
||||||
isResendPartyItemSatisfied,
|
isResendPartyItemSatisfied,
|
||||||
|
normalizeResendRequestedItemKey,
|
||||||
|
normalizeResendRequestedItemsList,
|
||||||
} from "src/Types&Enums/blame-request-management/resend-item-ui";
|
} 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 { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
@@ -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 { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema";
|
||||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.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 { UploadContext } from "./entities/schema/blame-voice.schema";
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
@@ -309,6 +317,7 @@ export class RequestManagementService {
|
|||||||
private readonly userSign: UserSignDbService,
|
private readonly userSign: UserSignDbService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
|
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||||
private readonly autoCloseRequestService: AutoCloseRequestService,
|
private readonly autoCloseRequestService: AutoCloseRequestService,
|
||||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
@@ -2370,9 +2379,6 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) {
|
if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) {
|
||||||
console.log(
|
|
||||||
new Date(p.createdAt).toLocaleTimeString("fa-IR").split(","),
|
|
||||||
);
|
|
||||||
res.listOfFirstParty.push({
|
res.listOfFirstParty.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
blameStatus: p.blameStatus,
|
blameStatus: p.blameStatus,
|
||||||
@@ -2746,76 +2752,28 @@ export class RequestManagementService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateDamageExpertStats(
|
private async recordBlameExpertActivity(args: {
|
||||||
expertId: string,
|
expertId: string;
|
||||||
requestId: string,
|
tenantId: string;
|
||||||
type: "checked" | "handled",
|
requestId: string;
|
||||||
|
eventType: ExpertFileActivityType;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (
|
||||||
|
!Types.ObjectId.isValid(args.expertId) ||
|
||||||
|
!Types.ObjectId.isValid(args.tenantId) ||
|
||||||
|
!Types.ObjectId.isValid(args.requestId)
|
||||||
) {
|
) {
|
||||||
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
|
|
||||||
console.warn("Invalid expertId, requestId, or type");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await this.expertFileActivityDbService.recordEvent({
|
||||||
// Validate that both expertId and requestId are valid ObjectId strings
|
expertId: args.expertId,
|
||||||
if (!Types.ObjectId.isValid(expertId)) {
|
tenantId: args.tenantId,
|
||||||
console.warn("Invalid ObjectId format for expertId:", expertId);
|
fileId: args.requestId,
|
||||||
return;
|
fileType: ExpertFileKind.BLAME,
|
||||||
}
|
eventType: args.eventType,
|
||||||
|
idempotencyKey: args.idempotencyKey,
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
private async handleDisagreement(
|
||||||
@@ -3006,11 +2964,17 @@ export class RequestManagementService {
|
|||||||
request._id.toString() :
|
request._id.toString() :
|
||||||
String(request._id);
|
String(request._id);
|
||||||
|
|
||||||
await this.updateDamageExpertStats(
|
const tenantIdStr =
|
||||||
actorIdStr,
|
request.firstPartyDetails?.firstPartyClient?.clientId?.toString?.() ||
|
||||||
requestIdStr,
|
request.secondPartyDetails?.secondPartyClient?.clientId?.toString?.() ||
|
||||||
"handled",
|
"";
|
||||||
);
|
await this.recordBlameExpertActivity({
|
||||||
|
expertId: actorIdStr,
|
||||||
|
tenantId: tenantIdStr,
|
||||||
|
requestId: requestIdStr,
|
||||||
|
eventType: ExpertFileActivityType.HANDLED,
|
||||||
|
idempotencyKey: `blame:${requestIdStr}:handled:${actorIdStr}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
|
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
|
||||||
$set: { isHandledStatsUpdated: true },
|
$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 row = userResendRequest as any;
|
||||||
const merged = {
|
const merged = {
|
||||||
uploadedDocuments: {
|
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
|
||||||
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
|
|
||||||
? { ...row.uploadedDocuments }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
resendVoiceId: row.resendVoiceId,
|
resendVoiceId: row.resendVoiceId,
|
||||||
resendVideoId: row.resendVideoId,
|
resendVideoId: row.resendVideoId,
|
||||||
userTextDescription: row.userTextDescription,
|
userTextDescription: row.userTextDescription,
|
||||||
@@ -6095,7 +6057,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
requestedItems: userResendRequest.requestedItems,
|
requestedItems: items,
|
||||||
description: userResendRequest.description || "",
|
description: userResendRequest.description || "",
|
||||||
completed: userResendRequest.completed || false,
|
completed: userResendRequest.completed || false,
|
||||||
requestedItemsUi: buildResendItemsWithUi(items),
|
requestedItemsUi: buildResendItemsWithUi(items),
|
||||||
@@ -6161,22 +6123,26 @@ export class RequestManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userResendRequest = resendParties[resendIndex];
|
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 row = userResendRequest as any;
|
||||||
const mergedRow = {
|
const mergedRow = {
|
||||||
uploadedDocuments: {
|
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
|
||||||
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
|
|
||||||
? { ...row.uploadedDocuments }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
resendVoiceId: row.resendVoiceId,
|
resendVoiceId: row.resendVoiceId,
|
||||||
resendVideoId: row.resendVideoId,
|
resendVideoId: row.resendVideoId,
|
||||||
userTextDescription: row.userTextDescription,
|
userTextDescription: row.userTextDescription,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAnyFile = Object.keys(files || {}).some((fieldName) => {
|
const safeFiles = files || {};
|
||||||
const fileArray = files[fieldName as keyof typeof files];
|
const hasAnyFile = Object.keys(safeFiles).some((fieldName) => {
|
||||||
|
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
|
||||||
return fileArray && fileArray.length > 0 && fileArray[0];
|
return fileArray && fileArray.length > 0 && fileArray[0];
|
||||||
});
|
});
|
||||||
const descTrim =
|
const descTrim =
|
||||||
@@ -6186,7 +6152,7 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
if (!hasAnyFile && !sendingDescription) {
|
if (!hasAnyFile && !sendingDescription) {
|
||||||
throw new BadRequestException(
|
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);
|
uploadedItems.push(ResendItemType.DESCRIPTION);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fieldName in files) {
|
for (const fieldName of Object.keys(safeFiles)) {
|
||||||
const fileArray = files[fieldName as keyof typeof files];
|
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
|
||||||
if (!fileArray || fileArray.length === 0) continue;
|
if (!fileArray || fileArray.length === 0) continue;
|
||||||
|
|
||||||
const file = fileArray[0];
|
const file = fileArray[0];
|
||||||
|
|
||||||
if (!requestedItems.includes(fieldName)) {
|
const canonKey = normalizeResendRequestedItemKey(fieldName);
|
||||||
|
if (!canonKey || !requestedItems.includes(canonKey)) {
|
||||||
throw new BadRequestException(
|
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({
|
const voiceDoc = await this.blameVoiceDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
requestId: new Types.ObjectId(requestId),
|
requestId: new Types.ObjectId(requestId),
|
||||||
@@ -6227,8 +6194,8 @@ export class RequestManagementService {
|
|||||||
`expert.resend.parties.${resendIndex}.resendVoiceId`
|
`expert.resend.parties.${resendIndex}.resendVoiceId`
|
||||||
] = (voiceDoc as any)._id;
|
] = (voiceDoc as any)._id;
|
||||||
mergedRow.resendVoiceId = (voiceDoc as any)._id;
|
mergedRow.resendVoiceId = (voiceDoc as any)._id;
|
||||||
uploadedItems.push(fieldName);
|
uploadedItems.push(canonKey);
|
||||||
} else if (fieldName === "video") {
|
} else if (canonKey === ResendItemType.VIDEO) {
|
||||||
const videoDoc = await this.blameVideoDbService.create({
|
const videoDoc = await this.blameVideoDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
requestId: new Types.ObjectId(requestId),
|
requestId: new Types.ObjectId(requestId),
|
||||||
@@ -6240,9 +6207,9 @@ export class RequestManagementService {
|
|||||||
`expert.resend.parties.${resendIndex}.resendVideoId`
|
`expert.resend.parties.${resendIndex}.resendVideoId`
|
||||||
] = (videoDoc as any)._id;
|
] = (videoDoc as any)._id;
|
||||||
mergedRow.resendVideoId = (videoDoc as any)._id;
|
mergedRow.resendVideoId = (videoDoc as any)._id;
|
||||||
uploadedItems.push(fieldName);
|
uploadedItems.push(canonKey);
|
||||||
} else {
|
} else {
|
||||||
const docType = fieldName as BlameDocumentType;
|
const docType = canonKey as BlameDocumentType;
|
||||||
const doc = await this.blameDocumentDbService.create({
|
const doc = await this.blameDocumentDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
requestId: new Types.ObjectId(requestId),
|
requestId: new Types.ObjectId(requestId),
|
||||||
@@ -6250,16 +6217,16 @@ export class RequestManagementService {
|
|||||||
documentType: docType,
|
documentType: docType,
|
||||||
});
|
});
|
||||||
updatePayload.$set[
|
updatePayload.$set[
|
||||||
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
|
`expert.resend.parties.${resendIndex}.uploadedDocuments.${canonKey}`
|
||||||
] = (doc as any)._id;
|
] = (doc as any)._id;
|
||||||
mergedRow.uploadedDocuments[fieldName] = (doc as any)._id;
|
mergedRow.uploadedDocuments[canonKey] = (doc as any)._id;
|
||||||
uploadedItems.push(fieldName);
|
uploadedItems.push(canonKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allItemsCompleted = (requestedItems as string[]).every((item) =>
|
const allItemsCompleted =
|
||||||
isResendPartyItemSatisfied(item, mergedRow),
|
requestedItems.length > 0 &&
|
||||||
);
|
requestedItems.every((item) => isResendPartyItemSatisfied(item, mergedRow));
|
||||||
|
|
||||||
if (allItemsCompleted) {
|
if (allItemsCompleted) {
|
||||||
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true;
|
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true;
|
||||||
@@ -6270,7 +6237,7 @@ export class RequestManagementService {
|
|||||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||||||
|
|
||||||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||||||
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
|
const allPartiesCompleted = updatedRequest?.expert?.resend?.parties?.every(
|
||||||
(p) => p.completed,
|
(p) => p.completed,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6446,6 +6413,41 @@ export class RequestManagementService {
|
|||||||
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
|
"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 {
|
} else {
|
||||||
finalStatus = CaseStatus.STOPPED;
|
finalStatus = CaseStatus.STOPPED;
|
||||||
message =
|
message =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -398,6 +399,11 @@ export class RequestManagementV2Controller {
|
|||||||
description:
|
description:
|
||||||
"Text description when expert requested ResendItemType.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({
|
storage: diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
cb(null, "./uploads");
|
const uploadDir = "./files/blame-resend";
|
||||||
|
mkdirSync(uploadDir, { recursive: true });
|
||||||
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const uniqueSuffix =
|
const uniqueSuffix =
|
||||||
@@ -439,12 +447,18 @@ export class RequestManagementV2Controller {
|
|||||||
video?: Express.Multer.File[];
|
video?: Express.Multer.File[];
|
||||||
},
|
},
|
||||||
@Body("description") description?: string,
|
@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(
|
return this.requestManagementService.uploadResendDocumentsV2(
|
||||||
requestId,
|
requestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
files,
|
files,
|
||||||
description,
|
mergedDescription,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface VerifyLookUpMessage {
|
|||||||
token: string;
|
token: string;
|
||||||
token2?: string;
|
token2?: string;
|
||||||
token3?: string;
|
token3?: string;
|
||||||
|
token10?: string;
|
||||||
receptor: string;
|
receptor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type TemplateArgs = {
|
|||||||
token: string;
|
token: string;
|
||||||
token2?: string;
|
token2?: string;
|
||||||
token3?: string;
|
token3?: string;
|
||||||
|
token10?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -40,6 +41,15 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
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> {
|
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
|
||||||
return this.sendTemplate({
|
return this.sendTemplate({
|
||||||
template: "yara724-invite-link",
|
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(
|
async sendThirdPartyExpertStartedReviewNotice(
|
||||||
params: {
|
params: {
|
||||||
receptor: string;
|
receptor: string;
|
||||||
|
fileKind: "blame" | "claim";
|
||||||
publicId: string;
|
publicId: string;
|
||||||
expertLastName: string;
|
expertLastName: string;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return this.sendTemplate({
|
return this.sendTemplate({
|
||||||
template:
|
template: "yara-expert-lock",
|
||||||
process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK ||
|
|
||||||
"yara-blame-expert-lock",
|
|
||||||
receptor: params.receptor,
|
receptor: params.receptor,
|
||||||
token: params.publicId,
|
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
|
||||||
token2: params.expertLastName || "کارشناس",
|
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();
|
.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) {
|
async updateOne(filter: any, update: any) {
|
||||||
return this.damageExpertModel.updateOne(filter, update);
|
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();
|
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) {
|
async updateOne(filter: any, update: any) {
|
||||||
return this.expertModel.updateOne(filter, update);
|
return this.expertModel.updateOne(filter, update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ import {
|
|||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
|
||||||
export class RequestStats {
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
totalHandled: number;
|
|
||||||
|
|
||||||
@Prop({ default: 0 })
|
|
||||||
totalChecked: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
|
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class PersonalInfo {
|
export class PersonalInfo {
|
||||||
@@ -133,6 +123,9 @@ export class DamageExpertModel {
|
|||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
clientKey?: Types.ObjectId;
|
clientKey?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: PersonalInfoSchema })
|
@Prop({ type: PersonalInfoSchema })
|
||||||
personalInfo?: PersonalInfo;
|
personalInfo?: PersonalInfo;
|
||||||
|
|
||||||
@@ -166,19 +159,6 @@ export class DamageExpertModel {
|
|||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
city: 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;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,10 +171,3 @@ DamageExpertDbSchema.pre("save", function (next) {
|
|||||||
}
|
}
|
||||||
next();
|
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 { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { Types } from "mongoose";
|
||||||
import { Degrees } from "src/Types&Enums/degrees.enum";
|
import { Degrees } from "src/Types&Enums/degrees.enum";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
|
|
||||||
@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 })
|
@Schema({ collection: "expert", versionKey: false, timestamps: true })
|
||||||
export class ExpertModel {
|
export class ExpertModel {
|
||||||
@Prop({})
|
@Prop({})
|
||||||
@@ -76,30 +66,14 @@ export class ExpertModel {
|
|||||||
@Prop({})
|
@Prop({})
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, index: true })
|
||||||
|
branchId?: Types.ObjectId;
|
||||||
|
|
||||||
@Prop({ type: "string" })
|
@Prop({ type: "string" })
|
||||||
otp: 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;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExpertDbSchema = SchemaFactory.createForClass(ExpertModel);
|
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 { Types } from "mongoose";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
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({
|
@Schema({
|
||||||
collection: "field-expert",
|
collection: "field-expert",
|
||||||
versionKey: false,
|
versionKey: false,
|
||||||
@@ -48,31 +37,8 @@ export class FieldExpertModel {
|
|||||||
@Prop({ type: "string", default: "" })
|
@Prop({ type: "string", default: "" })
|
||||||
otp: 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;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FieldExpertDbSchema =
|
export const FieldExpertDbSchema =
|
||||||
SchemaFactory.createForClass(FieldExpertModel);
|
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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
|
||||||
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
|
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
|
||||||
import { UserDbService } from "./entities/db-service/user.db.service";
|
import { UserDbService } from "./entities/db-service/user.db.service";
|
||||||
|
import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service";
|
||||||
import {
|
import {
|
||||||
DamageExpertDbSchema,
|
DamageExpertDbSchema,
|
||||||
DamageExpertModel,
|
DamageExpertModel,
|
||||||
} from "./entities/schema/damage-expert.schema";
|
} from "./entities/schema/damage-expert.schema";
|
||||||
|
import {
|
||||||
|
ExpertFileActivity,
|
||||||
|
ExpertFileActivitySchema,
|
||||||
|
} from "./entities/schema/expert-file-activity.schema";
|
||||||
import { ExpertDbSchema } from "./entities/schema/expert.schema";
|
import { ExpertDbSchema } from "./entities/schema/expert.schema";
|
||||||
import {
|
import {
|
||||||
FieldExpertDbSchema,
|
FieldExpertDbSchema,
|
||||||
@@ -37,6 +42,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
|
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
|
||||||
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
|
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
|
||||||
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
|
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
|
||||||
|
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
|
||||||
]),
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HashModule,
|
HashModule,
|
||||||
@@ -49,6 +55,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
FieldExpertDbService,
|
FieldExpertDbService,
|
||||||
InsurerExpertDbService,
|
InsurerExpertDbService,
|
||||||
RegistrarDbService,
|
RegistrarDbService,
|
||||||
|
ExpertFileActivityDbService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
UserDbService,
|
UserDbService,
|
||||||
@@ -57,6 +64,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
FieldExpertDbService,
|
FieldExpertDbService,
|
||||||
InsurerExpertDbService,
|
InsurerExpertDbService,
|
||||||
RegistrarDbService,
|
RegistrarDbService,
|
||||||
|
ExpertFileActivityDbService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
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