Compare commits

...

46 Commits

Author SHA1 Message Date
908292b0c3 Merge pull request 'Fix steps' (#53) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#53
2026-05-01 18:16:50 +03:30
55ec6f1fd1 Fix steps 2026-05-01 17:52:25 +03:30
d33cff8438 Merge pull request 'Fix workflow steps' (#52) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#52
2026-05-01 16:01:53 +03:30
0bb13f4596 Fix workflow steps 2026-05-01 16:01:08 +03:30
70306d42e0 Merge pull request 'Fix damaged-parts flow bug on carAngles' (#51) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#51
2026-05-01 14:45:39 +03:30
d9dc4ecdff Fix damaged-parts flow bug on carAngles 2026-05-01 14:44:28 +03:30
f66fa5d7b4 Merge pull request 'YARA-867, YARA-868' (#50) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#50
2026-05-01 11:38:50 +03:30
6429cb0f2b Fix bugs 2026-05-01 11:37:24 +03:30
6f120b0066 YARA-868 2026-05-01 11:25:10 +03:30
8419ec06ae YARA-867 2026-05-01 11:14:05 +03:30
def4023185 Merge pull request 'Fix data returning owner on claimDetails' (#49) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#49
2026-04-30 13:31:28 +03:30
40606fecf1 Fix data returning owner on claimDetails 2026-04-30 13:30:58 +03:30
c567f93e85 Merge pull request 'Added sign endpoint for claim' (#48) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#48
2026-04-30 13:18:04 +03:30
a9846095c1 Added sign endpoint for claim 2026-04-30 13:17:23 +03:30
b6f0e3c821 Merge pull request 'YARA-855, YARA-856' (#47) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#47
2026-04-30 10:34:52 +03:30
bffb8a3b97 YARA-855 2026-04-30 10:32:53 +03:30
715a9f2467 YARA-856 2026-04-30 09:57:28 +03:30
3813c2b3e3 Merge pull request 'YARA-854, YARA-848, YARA-850' (#46) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#46
2026-04-29 20:24:00 +03:30
ebf8a9a624 YARA-850 2026-04-29 20:22:43 +03:30
993d809de2 YARA-854 2026-04-29 14:26:04 +03:30
80ef885d3b Merge pull request 'Fixed label_fa' (#45) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#45
2026-04-28 16:33:36 +03:30
SepehrYahyaee
3f608f63f1 Fixed label_fa 2026-04-28 16:32:27 +03:30
6019c9e954 Merge pull request 'YARA-853, YARA-857, YARA-858 and a couple of fixes' (#44) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#44
2026-04-28 15:40:31 +03:30
SepehrYahyaee
8ae6f2c91b Fix get details for insurer 2026-04-28 15:39:06 +03:30
SepehrYahyaee
f999313476 YARA-853 2026-04-28 15:22:04 +03:30
SepehrYahyaee
98f1d2caf5 YARA-857 2026-04-28 14:41:47 +03:30
SepehrYahyaee
bbd83da2d5 YARA-858 2026-04-28 14:27:12 +03:30
SepehrYahyaee
9296795166 Added factorLink and branchName support 2026-04-28 13:31:02 +03:30
SepehrYahyaee
f456443342 Fixed blame resend 2026-04-28 10:15:03 +03:30
SepehrYahyaee
bcedd8c6f3 Added GET car-other-parts in v2 as well; deprecated old endpoints and reordered swagger documents 2026-04-28 10:01:45 +03:30
8caf13cf18 Merge pull request 'YARA-833' (#43) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#43
2026-04-27 17:04:18 +03:30
SepehrYahyaee
e90f8dc33c YARA-833 2026-04-27 17:03:29 +03:30
4c4b1a1db7 Merge pull request 'Fixed legacy requestedCounts methods and statistics + claimLink address' (#42) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#42
2026-04-27 15:30:21 +03:30
SepehrYahyaee
c2f996cc28 Fixed legacy requestedCounts methods and statistics + claimLink address 2026-04-27 15:29:44 +03:30
362e02ddc4 Merge pull request 'Fix locks' (#41) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#41
2026-04-26 16:09:17 +03:30
SepehrYahyaee
4b0e8a3547 FIX LOCKS 2026-04-26 16:08:14 +03:30
SepehrYahyaee
885678df7d Fixed lock mechanism, and some resendItem types 2026-04-26 15:58:21 +03:30
8dffc46357 Merge pull request 'YARA-725, YARA-830, YARA-832' (#40) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#40
2026-04-26 11:44:54 +03:30
SepehrYahyaee
b5b3b722c6 YARA-725 2026-04-26 11:43:17 +03:30
SepehrYahyaee
4f8cb43883 YARA-832 2026-04-26 09:31:50 +03:30
SepehrYahyaee
6c2d178686 YARA-830 2026-04-26 09:16:58 +03:30
9854a58282 Merge pull request 'Fix' (#39) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#39
2026-04-25 17:38:23 +03:30
SepehrYahyaee
7184142137 Fix 2026-04-25 17:38:01 +03:30
d45975b6a7 Merge pull request 'YARA-837, YARA-836' (#38) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#38
2026-04-25 17:12:59 +03:30
SepehrYahyaee
05c5b70b4d YARA-837 2026-04-25 17:10:07 +03:30
SepehrYahyaee
5d2227b00b YARA-836 + 2 more sms 2026-04-25 16:53:56 +03:30
58 changed files with 4170 additions and 1200 deletions

View File

@@ -1,5 +1,55 @@
import { ResendItemType } from "./resendItemType.enum";
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
/**
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
*/
export function normalizeResendRequestedItemKey(raw: string): string | null {
const t = String(raw ?? "").trim();
if (!t) return null;
if (RESEND_ITEM_VALUES.has(t)) return t;
const lower = t.toLowerCase();
for (const v of RESEND_ITEM_VALUES) {
if (v.toLowerCase() === lower) return v;
}
return null;
}
/** Deduplicated list of valid requested item keys. */
export function normalizeResendRequestedItemsList(
items: string[] | undefined | null,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of items || []) {
const c = normalizeResendRequestedItemKey(String(raw));
if (c && !seen.has(c)) {
seen.add(c);
out.push(c);
}
}
return out;
}
/**
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
*/
export function cloneResendUploadedDocuments(
raw: unknown,
): Record<string, unknown> {
if (raw == null || typeof raw !== "object") return {};
if (raw instanceof Map) {
const o: Record<string, unknown> = {};
for (const [k, v] of raw.entries()) {
o[String(k)] = v;
}
return o;
}
return { ...(raw as Record<string, unknown>) };
}
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
@@ -11,7 +61,8 @@ export function getResendItemInputKind(item: string): ResendItemInputKind {
}
export function buildResendItemsWithUi(requestedItems: string[]) {
return requestedItems.map((item) => ({
const normalized = normalizeResendRequestedItemsList(requestedItems);
return normalized.map((item) => ({
item,
inputKind: getResendItemInputKind(item),
}));

View File

@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
CarCertificate = "carCertificate",
DrivingLicense = "drivingLicense",
CarGreenCard = "carGreenCard",
}
Plate = "plate",
CarPlate = "carPlate",
}

View File

@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
username: process.env.AI_USERNAME,
password: process.env.AI_PASSWORD,
},
timeout: 30000, // 30 second timeout
timeout: 1000, // 30 second timeout
};
private get profileOptions(): AxiosRequestConfig {

View File

@@ -22,9 +22,9 @@ import {
ApiParam,
ApiQuery,
ApiTags,
ApiOperation,
} from "@nestjs/swagger";
import { diskStorage } from "multer";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
) {}
@ApiParam({ name: "blameId" })
@ApiOperation({ deprecated: true })
@Post("/:blameId")
async createClaimRequest(
@Param("blameId") requestId: string,
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
}
@ApiBody({ type: CarDamagePartDto })
@ApiOperation({ deprecated: true })
@Patch("/car-part-damage/:claimRequestID")
@ApiParam({ name: "claimRequestID" })
async carPartDamage(
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("/car-other-part")
async getCarOtherParts() {
const carOtherPart = await readFile(
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({ deprecated: true })
@Patch("/car-other-part-damage/:claimRequestID")
async carOtherPartDamage(
@Param("claimRequestID") requestId: string,
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("required-documents-status/:claimRequestID")
@ApiParam({ name: "claimRequestID" })
async getRequiredDocumentsStatus(
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("car-part-image-required/:claimRequestID")
@ApiParam({ name: "claimRequestID" })
async getImageRequired(@Param("claimRequestID") requestId) {
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
enum: ClaimRequiredDocumentType,
description: "Type of required document to upload",
})
@ApiOperation({ deprecated: true })
@Patch("upload-required-document/:claimRequestID")
async uploadRequiredDocument(
@Param("claimRequestID") requestId: string,
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
name: "partId",
description: "The ID of the specific car part being photographed.",
})
@ApiOperation({ deprecated: true })
@Patch("capture-car-part-damage/:claimRequestID/:partId")
async captureCarPartDamage(
@Param("partId") partId: string,
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
)
@ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestID" })
@ApiOperation({ deprecated: true })
@Patch("car-capture/:claimRequestID")
async captureVideoCapture(
@Param("claimRequestID") requestId: string,
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("requests/")
async getRequest(@CurrentUser() currentUser) {
return await this.claimRequestManagementService.myRequests(currentUser);
}
@ApiOperation({ deprecated: true })
@Get("request/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
myRequests(
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
return this.claimRequestManagementService.requestDetails(requestId, user);
}
@ApiOperation({ deprecated: true })
@Put("request/reply/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@UseInterceptors(
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Put("request/resend/:claimRequestId/objection")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("application/json")
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Patch("request/resend/:claimRequestId")
@ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" })
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
* User satisfaction rating for a completed claim file.
* Only the damaged user (claim owner) can rate their claim after it is closed.
*/
@ApiOperation({ deprecated: true })
@Put("request/:claimRequestId/user-rating")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UserRatingDto })
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data")
@ApiParam({ name: "claimRequestId" })
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
@ApiBody({ type: InPersonVisitDto })
@ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit")
async inPersonVisit(
@Param("id") requestId: string,
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("branches/:insuranceId")
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches(
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("fanavaran-submit/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {

View File

@@ -67,6 +67,11 @@ import {
import { PublicIdService } from "src/utils/public-id/public-id.service";
import { ImageRequiredModel } from "./entites/schema/image-required.schema";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import { CreationMethod } from "src/request-management/entities/schema/request-management.schema";
@@ -79,6 +84,22 @@ import {
normalizeResendPartKeys,
partKeyAllowedForExpertResend,
} from "src/helpers/claim-expert-resend";
import {
canonicalizeClaimCarAngleKey,
getClaimCarAngleCaptureBlob,
getDamagedPartCaptureBlob,
hasClaimCarAngleCapture,
hasDamagedPartCapture,
legacyAngleDamagedPartFieldKeysToUnset,
legacyDamagedPartTitleEnFromPartKey,
resolveCanonicalDamagedPartStorageKey,
type ClaimCarAngleKey,
} from "src/helpers/claim-car-angle-media";
import {
ClaimVehicleTypeV2,
OUTER_PARTS_BY_CAR_TYPE,
OuterPartCatalogItem,
} from "src/static/outer-car-parts-catalog";
@Injectable()
export class ClaimRequestManagementService {
@@ -117,6 +138,7 @@ export class ClaimRequestManagementService {
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly publicIdService: PublicIdService,
@@ -126,6 +148,26 @@ export class ClaimRequestManagementService {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getOuterPartsCatalogV2(carType?: ClaimVehicleTypeV2): OuterPartCatalogItem[] {
if (carType) {
return (OUTER_PARTS_BY_CAR_TYPE[carType] || []).map((p) => ({
...p,
carType,
}));
}
const out: OuterPartCatalogItem[] = [];
for (const [type, items] of Object.entries(OUTER_PARTS_BY_CAR_TYPE)) {
const t = type as ClaimVehicleTypeV2;
for (const p of items) {
out.push({ ...p, carType: t });
}
}
return out.sort((a, b) => {
if (a.carType === b.carType) return a.id - b.id;
return String(a.carType).localeCompare(String(b.carType));
});
}
private userDamageDetail(blRequest: RequestManagementModel) {
const { firstPartyDetails: first, secondPartyDetails: second } = blRequest;
switch (blRequest.expertSubmitReply.guiltyUserId) {
@@ -1869,6 +1911,90 @@ export class ClaimRequestManagementService {
}
}
/**
* V2 response mapper:
* - convert factorLink ObjectId -> public URL
* - enrich daghi.branchId with branchName when available
*/
private async mapEvaluationForClient(evaluation: any): Promise<any | undefined> {
if (!evaluation) return undefined;
const mapReply = async (reply: any) => {
if (!reply) return reply;
const mapped = {
...(typeof reply?.toObject === "function" ? reply.toObject() : reply),
};
const parts = Array.isArray(mapped.parts) ? mapped.parts : [];
const factorIds = new Set<string>();
const branchIds = new Set<string>();
for (const p of parts) {
if (p?.factorLink && Types.ObjectId.isValid(String(p.factorLink))) {
factorIds.add(String(p.factorLink));
}
const daghi = p?.daghi;
const rawBranchId =
daghi && typeof daghi === "object" ? (daghi as any).branchId : undefined;
if (rawBranchId && Types.ObjectId.isValid(String(rawBranchId))) {
branchIds.add(String(rawBranchId));
}
}
const factorMap = new Map<string, string>();
await Promise.all(
Array.from(factorIds).map(async (id) => {
const factorDoc = await this.claimFactorsImageDbService.findById(id);
if (factorDoc?.path) {
factorMap.set(id, buildFileLink(factorDoc.path));
}
}),
);
const branchMap = new Map<string, string>();
await Promise.all(
Array.from(branchIds).map(async (id) => {
const branch = await this.branchDbService.findById(id);
if (branch?.name) {
branchMap.set(id, branch.name);
}
}),
);
mapped.parts = parts.map((part: any) => {
const nextPart = { ...part };
if (nextPart.factorLink && Types.ObjectId.isValid(String(nextPart.factorLink))) {
const link = factorMap.get(String(nextPart.factorLink));
if (link) nextPart.factorLink = link;
}
const daghi = nextPart.daghi;
if (daghi && typeof daghi === "object" && !Array.isArray(daghi)) {
const branchId = (daghi as any).branchId;
const branchName =
branchId && Types.ObjectId.isValid(String(branchId))
? branchMap.get(String(branchId))
: undefined;
nextPart.daghi = {
...daghi,
...(branchName ? { branchName } : {}),
};
}
return nextPart;
});
return mapped;
};
return {
...evaluation,
damageExpertReply: await mapReply(evaluation.damageExpertReply),
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
};
}
async submitUserReply(
requestId: string,
body: UserCommentDto,
@@ -2158,6 +2284,7 @@ export class ClaimRequestManagementService {
return {
message: "Factor uploaded successfully. Awaiting expert validation.",
factorId: factorRecord._id,
factorLink: buildFileLink(file.path),
url: buildFileLink(file.path),
};
} catch (error) {
@@ -2577,11 +2704,20 @@ export class ClaimRequestManagementService {
},
);
await this.damageExpertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
if (
Types.ObjectId.isValid(String(actorDetail.sub)) &&
Types.ObjectId.isValid(String(request.userClientKey)) &&
Types.ObjectId.isValid(String(requestId))
) {
await this.expertFileActivityDbService.recordEvent({
expertId: String(actorDetail.sub),
tenantId: String(request.userClientKey),
fileId: String(requestId),
fileType: ExpertFileKind.CLAIM,
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${requestId}:handled:inperson:${actorDetail.sub}`,
});
}
return updated;
}
@@ -2680,6 +2816,27 @@ export class ClaimRequestManagementService {
}, 0);
}
/** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */
private collectBranchIdsFromClaimExpertReply(reply: {
parts?: unknown[];
}): Set<string> {
const ids = new Set<string>();
const parts = reply?.parts;
if (!Array.isArray(parts)) {
return ids;
}
for (const p of parts) {
const d = (p as { daghi?: unknown })?.daghi;
if (d && typeof d === "object" && d !== null && "branchId" in d) {
const bid = (d as { branchId?: unknown }).branchId;
if (bid != null && Types.ObjectId.isValid(String(bid))) {
ids.add(String(bid));
}
}
}
return ids;
}
/**
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
*/
@@ -3719,8 +3876,37 @@ export class ClaimRequestManagementService {
);
}
// 5. Convert selected parts to storage format (simple array of strings)
// Backward compatibility: some clients may still send legacy `carPartDamage` object.
// 5. Validate by selected car type and resolve selected parts by ids/keys
const selectedCarType = (body as any)?.carType as
| ClaimVehicleTypeV2
| undefined;
if (!selectedCarType || !OUTER_PARTS_BY_CAR_TYPE[selectedCarType]) {
throw new BadRequestException(
"Vehicle type is required before selecting outer parts.",
);
}
const catalog = OUTER_PARTS_BY_CAR_TYPE[selectedCarType];
const byId = new Map<number, OuterPartCatalogItem>(catalog.map((p) => [p.id, p]));
const byKey = new Map<string, OuterPartCatalogItem>(catalog.map((p) => [p.key, p]));
const selectedFromIds: OuterPartCatalogItem[] = [];
if (
Array.isArray((body as any)?.selectedPartIds) &&
(body as any).selectedPartIds.length > 0
) {
for (const id of (body as any).selectedPartIds as number[]) {
const item = byId.get(id);
if (!item) {
throw new BadRequestException(
`Invalid outer part id for ${selectedCarType}: ${id}`,
);
}
selectedFromIds.push(item);
}
}
// Backward compatibility with selectedParts + legacy carPartDamage
let selectedParts = Array.isArray((body as any)?.selectedParts)
? ((body as any).selectedParts as string[])
: [];
@@ -3732,17 +3918,52 @@ export class ClaimRequestManagementService {
(body as any).carPartDamage as CarDamagePartDto,
);
}
if (selectedParts.length === 0) {
const selectedFromKeys: OuterPartCatalogItem[] = [];
for (const key of selectedParts) {
const item = byKey.get(key);
if (!item) {
throw new BadRequestException(
`Invalid outer part key for ${selectedCarType}: ${key}`,
);
}
selectedFromKeys.push(item);
}
const selectedItems =
selectedFromIds.length > 0 ? selectedFromIds : selectedFromKeys;
if (selectedItems.length === 0) {
throw new BadRequestException(
"selectedParts is required and must contain at least one item",
"selectedPartIds or selectedParts is required and must contain at least one item",
);
}
// At most two non-top sides allowed
const sideSet = new Set(
selectedItems
.map((p) => p.side)
.filter((s) => s !== "top"),
);
if (sideSet.size > 2) {
throw new BadRequestException(
`At most two of left/right/front/back can be selected. Selected: ${[...sideSet].join(", ")}`,
);
}
selectedParts = selectedItems.map((p) => p.key);
const selectedPartIds = selectedItems.map((p) => p.id);
const selectedOuterParts = selectedItems.map((p) => ({
id: p.id,
key: p.key,
side: p.side,
}));
// 6. Update claim case with selected parts and move to next step
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
{
'damage.selectedParts': selectedParts,
'damage.selectedOuterParts': selectedOuterParts,
'vehicle.carType': selectedCarType,
'status': ClaimCaseStatus.SELECTING_OTHER_PARTS,
'workflow.currentStep': ClaimWorkflowStep.SELECT_OTHER_PARTS,
'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
@@ -3759,6 +3980,8 @@ export class ClaimRequestManagementService {
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts: selectedParts,
selectedPartIds,
carType: selectedCarType,
partsCount: selectedParts?.length,
description: `User selected ${selectedParts?.length} damaged outer parts`,
},
@@ -3780,6 +4003,7 @@ export class ClaimRequestManagementService {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
selectedParts: selectedParts,
selectedPartIds,
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
@@ -4024,24 +4248,28 @@ export class ClaimRequestManagementService {
throw new ForbiddenException('Only the claim owner can view capture requirements');
}
// Helper to get part labels
// Build car-type aware outer-parts lookup (complete source: static catalog)
const selectedCarType = claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const catalogForType =
selectedCarType && OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
? OUTER_PARTS_BY_CAR_TYPE[selectedCarType]
: this.getOuterPartsCatalogV2();
const catalogByKey = new Map<string, OuterPartCatalogItem>();
for (const item of catalogForType) {
if (!catalogByKey.has(item.key)) catalogByKey.set(item.key, item);
}
const getPartLabel = (key: string): { fa: string; en: string } => {
const labels = {
hood: { fa: 'کاپوت', en: 'Hood' },
trunk: { fa: 'صندوق عقب', en: 'Trunk' },
roof: { fa: 'سقف', en: 'Roof' },
front_right_door: { fa: 'درب جلو راست', en: 'Front Right Door' },
front_left_door: { fa: 'درب جلو چپ', en: 'Front Left Door' },
rear_right_door: { fa: 'درب عقب راست', en: 'Rear Right Door' },
rear_left_door: { fa: 'درب عقب چپ', en: 'Rear Left Door' },
front_bumper: { fa: 'سپر جلو', en: 'Front Bumper' },
rear_bumper: { fa: 'سپر عقب', en: 'Rear Bumper' },
front_right_fender: { fa: 'گلگیر جلو راست', en: 'Front Right Fender' },
front_left_fender: { fa: 'گلگیر جلو چپ', en: 'Front Left Fender' },
rear_right_fender: { fa: 'گلگیر عقب راست', en: 'Rear Right Fender' },
rear_left_fender: { fa: 'گلگیر عقب چپ', en: 'Rear Left Fender' },
};
return labels[key] || { fa: key, en: key };
const catalogItem = catalogByKey.get(key);
if (catalogItem) {
const en = key
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
return { fa: catalogItem.titleFa, en };
}
return { fa: key, en: key };
};
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
@@ -4090,18 +4318,54 @@ export class ClaimRequestManagementService {
key: angle.key,
label_fa: angle.label_fa,
label_en: angle.label_en,
captured: !!hasCapture(claimCase.media?.carAngles as any, angle.key),
captured: hasClaimCarAngleCapture(
claimCase.media?.carAngles as any,
claimCase.media?.damagedParts as any,
angle.key as ClaimCarAngleKey,
),
}));
// Damaged parts from selected parts
const selectedParts = claimCase.damage?.selectedParts || [];
const selectedOuterParts = Array.isArray(claimCase.damage?.selectedOuterParts)
? claimCase.damage.selectedOuterParts
: [];
const selectedOuterPartByKey = new Map<
string,
{ id?: number; side?: string }
>(
selectedOuterParts.map((p) => [
p.key,
{
id: p.id,
side: p.side,
},
]),
);
const damagedParts = selectedParts.map(partKey => {
const label = getPartLabel(partKey);
const selectedMeta = selectedOuterPartByKey.get(partKey);
const catalogMeta = catalogByKey.get(partKey);
const side = selectedMeta?.side ?? catalogMeta?.side;
const sideFaMap: Record<string, string> = {
left: "چپ",
right: "راست",
front: "جلو",
back: "عقب",
top: "بالا",
};
const sideFa = side ? sideFaMap[side] : undefined;
return {
id: selectedMeta?.id ?? catalogMeta?.id,
key: partKey,
label_fa: label.fa,
side,
label_fa: sideFa ? `${label.fa} (${sideFa})` : label.fa,
label_en: label.en,
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
captured: hasDamagedPartCapture(
claimCase.media?.damagedParts as any,
partKey,
),
};
});
@@ -4489,8 +4753,24 @@ export class ClaimRequestManagementService {
`Part "${body.captureKey}" was not requested by the damage expert for this resend.`,
);
}
if (canonicalizeClaimCarAngleKey(body.captureKey)) {
throw new BadRequestException(
"During expert resend only damaged-part photos are allowed, not car angles (front/back/left/right).",
);
}
}
const angleCanon = canonicalizeClaimCarAngleKey(body.captureKey);
if (body.captureType === "angle" && !angleCanon) {
throw new BadRequestException(
`Invalid angle captureKey "${body.captureKey}". Use one of: front, back, left, right.`,
);
}
const treatAsAngle =
!isResendCapture &&
(body.captureType === "angle" ||
(body.captureType === "part" && angleCanon !== null));
const fileUrl = buildFileLink(file.path);
const captureData = {
path: file.path,
@@ -4502,7 +4782,7 @@ export class ClaimRequestManagementService {
const updateData: any = {
$push: {
history: {
type: body.captureType === 'angle' ? 'ANGLE_CAPTURED' : 'PART_CAPTURED',
type: treatAsAngle ? "ANGLE_CAPTURED" : "PART_CAPTURED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
@@ -4512,19 +4792,51 @@ export class ClaimRequestManagementService {
metadata: {
captureType: body.captureType,
captureKey: body.captureKey,
...(treatAsAngle && angleCanon
? { resolvedCarAngleKey: angleCanon }
: {}),
fileName: file.filename,
},
},
},
};
if (body.captureType === 'angle') {
updateData[`media.carAngles.${body.captureKey}`] = captureData;
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
if (treatAsAngle && angleCanon) {
updateData[`media.carAngles.${angleCanon}`] = captureData;
const unsetFieldKeys = legacyAngleDamagedPartFieldKeysToUnset(
angleCanon,
body.captureKey,
selectedBefore,
);
if (unsetFieldKeys.length) {
updateData.$unset = {};
for (const k of unsetFieldKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
}
}
} else {
updateData[`media.damagedParts.${body.captureKey}`] = captureData;
const canonicalPartKey = resolveCanonicalDamagedPartStorageKey(
body.captureKey,
selectedBefore,
);
updateData[`media.damagedParts.${canonicalPartKey}`] = captureData;
const raw = String(body.captureKey ?? "").trim();
const titleLegacy =
legacyDamagedPartTitleEnFromPartKey(canonicalPartKey);
const unsetPartKeys = new Set<string>();
if (raw && raw !== canonicalPartKey) unsetPartKeys.add(raw);
if (titleLegacy && titleLegacy !== canonicalPartKey) {
unsetPartKeys.add(titleLegacy);
}
if (unsetPartKeys.size) {
updateData.$unset = updateData.$unset || {};
for (const k of unsetPartKeys) {
updateData.$unset[`media.damagedParts.${k}`] = "";
}
}
}
const selectedBefore = (claimCase.damage?.selectedParts || []) as string[];
if (
isResendCapture &&
body.captureType === "part" &&
@@ -4570,8 +4882,16 @@ export class ClaimRequestManagementService {
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
const selectedParts = updatedClaim?.damage?.selectedParts || [];
const anglesCaptured = anglesKeys.filter(k => hasCapture(carAnglesData, k)).length;
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
const anglesCaptured = anglesKeys.filter((k) =>
hasClaimCarAngleCapture(
carAnglesData,
damagedPartsData,
k as ClaimCarAngleKey,
),
).length;
const partsCaptured = selectedParts.filter((p) =>
hasDamagedPartCapture(damagedPartsData, p),
).length;
const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
@@ -4906,6 +5226,182 @@ export class ClaimRequestManagementService {
return userRating;
}
/**
* V2: Claim owner signs final priced expert reply (single-party), same intent as blame
* `PUT …/sign/:requestId` — only when case status is WAITING_FOR_INSURER_APPROVAL and
* workflow is at INSURER_REVIEW (not factor-validation queue at EXPERT_COST_EVALUATION).
*/
async submitOwnerInsurerApprovalSignV2(
claimRequestId: string,
agree: boolean,
branchId: string,
signFile: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string; fullName?: string },
): Promise<{
message: string;
claimRequestId: string;
status: ClaimCaseStatus;
claimStatus: ClaimStatus;
currentStep?: ClaimWorkflowStep;
accepted: boolean;
}> {
if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id");
}
if (!signFile) {
throw new BadRequestException("A signature file is required");
}
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException("Claim request not found");
}
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException(
"Only the claim owner (damaged party) can sign at this step.",
);
}
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
throw new BadRequestException(
`Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`,
);
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
throw new BadRequestException(
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
);
}
if (claimCase.claimStatus !== ClaimStatus.APPROVED) {
throw new BadRequestException(
`Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`,
);
}
if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("You have already submitted a signature for this claim.");
}
const activeReply =
claimCase.evaluation?.damageExpertReplyFinal ||
claimCase.evaluation?.damageExpertReply;
if (!activeReply?.submittedAt) {
throw new BadRequestException("No expert reply is available to sign off on yet.");
}
const rawBranchId = (branchId ?? "").trim();
if (!rawBranchId || !Types.ObjectId.isValid(rawBranchId)) {
throw new BadRequestException(
"branchId is required and must be a valid MongoDB ObjectId.",
);
}
const branchDoc = await this.branchDbService.findById(rawBranchId);
if (!branchDoc) {
throw new NotFoundException(`Branch with ID ${rawBranchId} not found.`);
}
const ownerClientId = claimCase.owner?.clientId;
if (!ownerClientId || String(branchDoc.clientKey) !== String(ownerClientId)) {
throw new ForbiddenException(
"This branch does not belong to the insurer for this claim.",
);
}
const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply(
activeReply as { parts?: unknown[] },
);
if (
branchIdsOnPricing.size > 0 &&
!branchIdsOnPricing.has(String(rawBranchId))
) {
throw new BadRequestException(
"branchId must match a branch used in the expert pricing for this claim.",
);
}
const signDoc = await this.claimSignDbService.create({
fileName: signFile.filename,
userId: effectiveUserId,
path: signFile.path,
requestId: new Types.ObjectId(claimRequestId),
} as any);
const signId = new Types.ObjectId(String((signDoc as any)._id));
const signedAt = new Date();
const approvalPayload = {
agree,
branchId: new Types.ObjectId(rawBranchId),
signDetailId: signId,
signedAt,
};
const historyActor = {
actorId: new Types.ObjectId(effectiveUserId),
actorName: claimCase.owner?.fullName || "User",
actorType: "user",
};
if (!agree) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
"evaluation.ownerInsurerApproval": approvalPayload,
"workflow.nextStep": null,
},
$push: {
history: {
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
actor: historyActor,
timestamp: signedAt,
metadata: { branchId: rawBranchId },
},
},
});
return {
message:
"Your rejection has been recorded. This claim is marked rejected and will not proceed.",
claimRequestId,
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
currentStep: claimCase.workflow?.currentStep,
accepted: false,
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"evaluation.ownerInsurerApproval": approvalPayload,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
history: {
type: "OWNER_SIGNED_INSURER_APPROVAL",
actor: historyActor,
timestamp: signedAt,
metadata: { branchId: rawBranchId },
},
},
});
return {
message: "Your signature has been recorded. The claim is completed.",
claimRequestId,
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
accepted: true,
};
}
/**
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
*/
@@ -5000,17 +5496,44 @@ export class ClaimRequestManagementService {
}
const carAnglesData = claim.media?.carAngles as any;
const damagedPartsForAngles = claim.media?.damagedParts as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
const cap = getClaimCarAngleCaptureBlob(
carAnglesData,
damagedPartsForAngles,
k as ClaimCarAngleKey,
) as { url?: string; path?: string } | undefined;
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
const getPartLabelFa = (key: string): string => {
const labels: Record<string, string> = {
hood: 'کاپوت',
trunk: 'صندوق عقب',
roof: 'سقف',
front_right_door: 'درب جلو راست',
front_left_door: 'درب جلو چپ',
rear_right_door: 'درب عقب راست',
rear_left_door: 'درب عقب چپ',
front_bumper: 'سپر جلو',
rear_bumper: 'سپر عقب',
front_right_fender: 'گلگیر جلو راست',
front_left_fender: 'گلگیر جلو چپ',
rear_right_fender: 'گلگیر عقب راست',
rear_left_fender: 'گلگیر عقب چپ',
};
return labels[key] || key;
};
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
const damagedParts: Record<
string,
{ label_fa: string; captured: boolean; url?: string }
> = {};
const resendPartKeys = normalizeResendPartKeys(
claim.evaluation?.damageExpertResend?.resendCarParts,
);
@@ -5019,8 +5542,11 @@ export class ClaimRequestManagementService {
...resendPartKeys,
]);
for (const p of partKeysForDetails) {
const cap = hasCapture(damagedPartsData, p);
const cap = getDamagedPartCaptureBlob(damagedPartsData, p) as
| { url?: string; path?: string }
| undefined;
damagedParts[p] = {
label_fa: getPartLabelFa(p),
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
@@ -5045,6 +5571,10 @@ export class ClaimRequestManagementService {
const ownerData = claim.owner
? {
userId: claim.owner.userId?.toString(),
clientId: claim.owner.clientId?.toString(),
...(claim.owner.userClientKey
? { userClientKey: claim.owner.userClientKey.toString() }
: {}),
fullName: claim.owner.fullName,
}
: undefined;
@@ -5061,6 +5591,8 @@ export class ClaimRequestManagementService {
}
: undefined;
const mappedEvaluation = await this.mapEvaluationForClient(claim.evaluation);
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
@@ -5071,7 +5603,7 @@ export class ClaimRequestManagementService {
nextStep: claim.workflow?.nextStep,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
...(isExpertViewer ? { owner: ownerData } : {}),
...(ownerData ? { owner: ownerData } : {}),
vehicle: claim.vehicle,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
@@ -5080,6 +5612,12 @@ export class ClaimRequestManagementService {
carAngles,
damagedParts,
expertResend,
evaluation: mappedEvaluation
? {
damageExpertReply: mappedEvaluation.damageExpertReply,
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
}
: undefined,
...(isExpertViewer
? {
userRating: claim.userRating

View File

@@ -2,7 +2,9 @@ import {
Controller,
HttpException,
InternalServerErrorException,
BadRequestException,
Param,
Query,
Post,
Patch,
Put,
@@ -12,17 +14,23 @@ import {
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { readFile } from "node:fs/promises";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname } from "path";
import { Types } from "mongoose";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
import {
OuterPartCatalogItemDto,
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "./dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
@@ -35,6 +43,7 @@ import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
@ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management")
@@ -76,7 +85,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Get Claim Details (V2)",
description:
"Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.",
"Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.",
})
@ApiResponse({
status: 200,
@@ -223,6 +232,91 @@ export class ClaimRequestManagementV2Controller {
}
}
/**
* V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW).
*/
@Put("request/:claimRequestId/owner-insurer-approval/sign")
@ApiParam({
name: "claimRequestId",
description: "Claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Sign final claim pricing (owner)",
description:
"Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " +
"Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " +
"`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " +
"If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
})
@ApiBody({
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree", "branchId"],
properties: {
sign: { type: "string", format: "binary", description: "Signature image" },
agree: {
type: "boolean",
description: "true to accept expert pricing and complete the claim",
},
branchId: {
type: "string",
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
example: "507f1f77bcf86cd799439011",
},
},
},
})
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Already signed" })
@UseInterceptors(
FileInterceptor("sign", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-sign",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
callback(null, `${base}-${unique}${ex}`);
},
}),
}),
)
async submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id");
}
const agreed =
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
try {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit signature",
);
}
}
@Post("create-from-blame/:blameRequestId")
@ApiParam({
name: "blameRequestId",
@@ -250,6 +344,66 @@ export class ClaimRequestManagementV2Controller {
/**
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
*/
@Get("outer-parts-catalog")
@ApiOperation({
summary: "Get outer parts catalog (V2)",
description:
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
})
@ApiResponse({
status: 200,
description: "Outer parts catalog",
type: [OuterPartCatalogItemDto],
})
async getOuterPartsCatalog(
@Query("carType") carType?: ClaimVehicleTypeV2,
): Promise<OuterPartCatalogItemDto[]> {
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
}
@Get("branches/:insuranceId")
@ApiOperation({
summary: "Get insurer branches (V2)",
description:
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
})
@ApiParam({
name: "insuranceId",
description: "Insurer client id (MongoDB ObjectId)",
example: "60d5ec49e7b2f8001c8e4d2a",
})
@ApiResponse({
status: 200,
description: "List of branches for insurer",
})
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
return await this.claimRequestManagementService.retrieveInsuranceBranches(
insuranceId,
);
}
@Get("car-other-part")
@ApiOperation({
summary: "Get other (non-body) parts catalog",
description:
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
})
@ApiResponse({
status: 200,
description: "Other parts catalog",
})
async getCarOtherPartsV2() {
const raw = await readFile(
`${process.cwd()}/src/static/car-part.json`,
"utf-8",
);
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
@Patch("select-outer-parts/:claimRequestId")
@ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
@@ -284,41 +438,35 @@ export class ClaimRequestManagementV2Controller {
})
@ApiBody({
type: SelectOuterPartsV2Dto,
description: "Array of selected damaged outer parts",
description:
"Selected vehicle type + selected outer part IDs from catalog",
examples: {
example1: {
summary: "Minor front damage",
summary: "Sedan - minor front damage",
value: {
selectedParts: ["hood", "front_bumper", "front_right_fender"],
carType: "sedan",
selectedPartIds: [19, 21, 16],
},
},
example2: {
summary: "Side impact damage",
summary: "SUV - left side impact",
value: {
selectedParts: [
"front_left_door",
"rear_left_door",
"front_left_fender",
"rear_left_fender",
],
carType: "suv",
selectedPartIds: [102, 103, 104, 107],
},
},
example3: {
summary: "Rear-end collision",
summary: "Hatchback - rear-end collision",
value: {
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
carType: "hatchback",
selectedPartIds: [225, 226, 210],
},
},
example4: {
summary: "Multiple damage areas",
summary: "Pickup - two sides + roof",
value: {
selectedParts: [
"hood",
"front_bumper",
"front_right_door",
"rear_bumper",
"trunk",
],
carType: "pickup",
selectedPartIds: [319, 312, 330],
},
},
},

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
/**
* DTO for required document item
@@ -93,6 +94,12 @@ export class DamagedPartItem {
example: false,
})
captured: boolean;
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
@ApiPropertyOptional({ example: 12 })
@IsOptional()
@IsInt()
id?: number;
}
/**

View File

@@ -43,9 +43,13 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Owner info' })
@ApiPropertyOptional({
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
})
owner?: {
userId: string;
clientId?: string;
userClientKey?: string;
fullName?: string;
};
@@ -76,7 +80,7 @@ export class ClaimDetailsV2ResponseDto {
carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
@@ -84,6 +88,15 @@ export class ClaimDetailsV2ResponseDto {
})
expertResend?: ExpertResendDetailsV2Dto;
@ApiPropertyOptional({
description: "Damage expert opinion(s): initial and final (after objection).",
type: Object,
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
userRating?: {
progressSpeed: number;

View File

@@ -1,5 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
import {
ClaimVehicleTypeV2,
OuterPartSideV2,
} from "src/static/outer-car-parts-catalog";
/**
* Enum for valid outer car parts that can be damaged
@@ -43,7 +47,7 @@ export class SelectOuterPartsV2Dto {
minItems: 1,
maxItems: 13,
})
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
@IsOptional()
@IsArray({ message: 'selectedParts must be an array' })
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
@@ -51,7 +55,29 @@ export class SelectOuterPartsV2Dto {
each: true,
message: 'Invalid part name. Must be one of the valid outer car parts',
})
selectedParts: OuterCarPart[];
selectedParts?: OuterCarPart[];
@ApiProperty({
description: 'Selected outer part IDs from catalog',
example: [9, 10, 4],
type: [Number],
required: false,
})
@IsOptional()
@IsArray({ message: 'selectedPartIds must be an array' })
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
selectedPartIds?: number[];
@ApiProperty({
description: 'Vehicle type for validating available outer parts',
enum: ClaimVehicleTypeV2,
required: true,
})
@IsNotEmpty()
@IsEnum(ClaimVehicleTypeV2)
carType?: ClaimVehicleTypeV2;
}
/**
@@ -76,6 +102,13 @@ export class SelectOuterPartsV2ResponseDto {
})
selectedParts: string[];
@ApiProperty({
description: 'Selected part IDs',
example: [9, 7, 11],
type: [Number],
})
selectedPartIds: number[];
@ApiProperty({
description: 'Current workflow step',
example: 'SELECT_OUTER_PARTS',
@@ -94,3 +127,30 @@ export class SelectOuterPartsV2ResponseDto {
})
message: string;
}
export class SetClaimVehicleTypeV2Dto {
@ApiProperty({
description: "Vehicle type selected by user",
enum: ClaimVehicleTypeV2,
})
@IsNotEmpty()
@IsEnum(ClaimVehicleTypeV2)
carType: ClaimVehicleTypeV2;
}
export class OuterPartCatalogItemDto {
@ApiProperty()
id: number;
@ApiProperty()
key: string;
@ApiProperty()
titleFa: string;
@ApiProperty({ enum: OuterPartSideV2 })
side: OuterPartSideV2;
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false })
carType?: ClaimVehicleTypeV2;
}

View File

@@ -1,6 +1,20 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
@Schema({ _id: false })
export class SelectedOuterPartV2 {
@Prop({ type: Number })
id: number;
@Prop({ type: String })
key: string;
@Prop({ type: String })
side: string;
}
export const SelectedOuterPartV2Schema =
SchemaFactory.createForClass(SelectedOuterPartV2);
@Schema({ _id: false })
export class ClaimDamageSelection {
/**
@@ -10,6 +24,10 @@ export class ClaimDamageSelection {
@Prop({ type: [String], default: [] })
selectedParts?: string[];
/** Structured selected outer parts with id + side for better downstream handling. */
@Prop({ type: [SelectedOuterPartV2Schema], default: [] })
selectedOuterParts?: SelectedOuterPartV2[];
/**
* V2: Array of selected other (non-body) damaged parts
* Examples: ['engine', 'suspension', 'headlight']

View File

@@ -76,6 +76,25 @@ export class ClaimUserComment {
export const ClaimUserCommentSchema =
SchemaFactory.createForClass(ClaimUserComment);
/** Owner acceptance + signature after expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). */
@Schema({ _id: false })
export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true })
agree: boolean;
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
@Prop({ type: Types.ObjectId })
branchId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
@Prop({ type: Date, default: () => new Date() })
signedAt?: Date;
}
export const ClaimOwnerInsurerApprovalSchema =
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
@Schema({ _id: false })
export class ClaimExpertReply {
@Prop({ type: String })
@@ -205,6 +224,9 @@ export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number;
@Prop({ type: Number, min: 0, max: 5 })
botRating?: number;
}
export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating);
@@ -251,6 +273,9 @@ export class ClaimEvaluation {
@Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating;
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
@Prop({ type: String })
visitLocation?: string;

View File

@@ -1,6 +1,7 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import {
ExpertProfileSnapshot,
ExpertProfileSnapshotSchema,
@@ -23,6 +24,22 @@ export class ClaimActorLock {
}
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
@Schema({ _id: false })
export class ClaimPreLockQueueSnapshot {
@Prop({ type: String, enum: ClaimStatus, required: true })
claimStatus: ClaimStatus;
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
currentStep: ClaimWorkflowStep;
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
nextStep?: ClaimWorkflowStep;
}
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
ClaimPreLockQueueSnapshot,
);
@Schema({ _id: false })
export class ClaimWorkflow {
@Prop({ type: String, enum: ClaimWorkflowStep })
@@ -40,8 +57,15 @@ export class ClaimWorkflow {
@Prop({ type: Date })
lockedAt?: Date;
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
@Prop({ type: Date })
expiredAt?: Date;
@Prop({ type: ClaimActorLockSchema })
lockedBy?: ClaimActorLock;
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
}
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -7,25 +7,27 @@ import {
} from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClientService } from "./client.service";
import { ClientDto } from "./dto/create-client.dto";
@Controller("client")
@ApiTags("client-management")
@ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.ADMIN)
export class ClientController {
constructor(private readonly clientService: ClientService) {}
@Post()
@UseGuards(GlobalGuard)
@ApiBearerAuth()
async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client);
}
@Get()
@ApiBearerAuth()
@UseGuards(GlobalGuard)
async getClient(@CurrentUser() user) {
return await this.clientService.getClients();
}

View File

@@ -1,31 +1,44 @@
import { Injectable } from "@nestjs/common";
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { Types } from "mongoose";
class ClientName {
@ApiProperty({})
@IsString()
@IsNotEmpty()
persian: string;
@ApiProperty({})
@IsString()
@IsNotEmpty()
english: string;
}
class Property {
@ApiProperty({})
@IsString()
@IsNotEmpty()
smsApiKey: string;
}
@Injectable()
export class ClientDto {
@ApiProperty({ required: true })
@ValidateNested()
@Type(() => ClientName)
clientName: ClientName;
@ApiProperty({ required: true })
@IsNumber()
clientCode: number;
@ApiProperty({ required: false })
property: Property;
@IsOptional()
@ValidateNested()
@Type(() => Property)
property?: Property;
@ApiProperty({ examples: ["legal", "genuine"] })
@IsIn(["legal", "genuine"])
useExpertMode: "legal" | "genuine";
}
export class ClientDtoRs {
@@ -35,6 +48,9 @@ export class ClientDtoRs {
useExpertsMode: string;
constructor(readonly client) {
this.persian = client.clientName.persian;
this.english = client.clientName.english;
this.clientId = client._id;
this.useExpertsMode = client.useExpertMode;
}
}

View File

@@ -25,7 +25,7 @@ export class BranchDbService {
async findAll(insuranceId: string): Promise<BranchModel[]> {
return await this.branchModel.find({
clientKey: new Types.ObjectId(insuranceId),
});
}, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 });
}
async findById(id: string): Promise<BranchModel | null> {

View File

@@ -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());
},
);

View File

@@ -117,6 +117,7 @@ export class AllRequestDtoV2 {
type: string;
blameStatus: string;
partiesInitialForms: { firstParty: string; secondParty: string };
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
}
export class AllRequestDtoRsV2 {

View File

@@ -18,6 +18,7 @@ import {
ApiParam,
ApiProduces,
ApiTags,
ApiOperation,
} from "@nestjs/swagger";
import { Response, Request } from "express";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
@@ -45,30 +46,35 @@ export class ExpertBlameController {
// TODO role guard for expert fix
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get()
async findAll(@CurrentUser() actor, @ClientKey() client) {
return await this.expertBlameService.findAll(actor);
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get(":id")
async findOne(@Param("id") id: string, @CurrentUser() actor) {
return await this.expertBlameService.findOne(id, actor.sub);
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("lock/:id")
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
return await this.expertBlameService.lockRequest(id, actor);
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get("request/accident-fields")
async getAccidentFields() {
return await this.expertBlameService.getAccidentField();
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/submit/:id")
@ApiBody({ type: SubmitReplyDto })
@ApiParam({ name: "id" })
@@ -95,6 +101,7 @@ export class ExpertBlameController {
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/resend/first/:id")
@ApiBody({ type: ResendFirstPartyDto })
@ApiParam({ name: "id" })
@@ -107,6 +114,7 @@ export class ExpertBlameController {
return this.handleResendRequest(id, body, actor.sub, req);
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Put("reply/resend/second/:id")
@Roles(RoleEnum.EXPERT)
@ApiBody({ type: ResendSecondPartyDto })
@@ -120,6 +128,7 @@ export class ExpertBlameController {
return this.handleResendRequest(id, body, actor.sub, req);
}
@Roles(RoleEnum.EXPERT)
@ApiOperation({ deprecated: true })
@Get("stream/:requestId")
@ApiParam({ name: "requestId" })
async streamVideo(@Param("requestId") requestId: string) {
@@ -127,6 +136,7 @@ export class ExpertBlameController {
}
@UseInterceptors(LoggingInterceptor)
@ApiOperation({ deprecated: true })
@Get("voice/:requestId/:voiceId")
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
@ApiParam({ name: "requestId" })
@@ -149,6 +159,7 @@ export class ExpertBlameController {
}
@ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit")
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertBlameService.inPersonVisit(requestId, actor);

View File

@@ -34,7 +34,10 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
import {
buildResendItemsWithUi,
normalizeResendRequestedItemsList,
} from "src/Types&Enums/blame-request-management/resend-item-ui";
import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { ResendRequestDto } from "./dto/resend.dto";
@@ -46,6 +49,12 @@ import { RequestManagementService } from "src/request-management/request-managem
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -87,6 +96,7 @@ export class ExpertBlameService {
private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) { }
/** Load immutable profile fields from `expert` for the acting field expert. */
@@ -304,15 +314,7 @@ export class ExpertBlameService {
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
const la = w.lockedAt;
if (!la) {
staleIds.add(String(doc._id));
continue;
}
if (
Date.now() >=
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
) {
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
staleIds.add(String(doc._id));
}
}
@@ -325,6 +327,7 @@ export class ExpertBlameService {
if (w) {
w.locked = false;
delete w.lockedAt;
delete w.expiredAt;
delete w.lockedBy;
}
}
@@ -352,6 +355,15 @@ export class ExpertBlameService {
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
const parties = (doc.parties ?? []) as Array<{
role?: string;
vehicle?: {
name?: string;
inquiry?: {
mapped?: {
MapTypNam?: string;
CarName?: string;
};
};
};
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
@@ -381,9 +393,43 @@ export class ExpertBlameService {
firstParty: statementToFormKey(firstParty?.statement) ?? "",
secondParty: statementToFormKey(secondParty?.statement) ?? "",
},
partiesVehicles: {
firstPartyVehicle:
firstParty?.vehicle?.name ||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
firstParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
secondPartyVehicle:
secondParty?.vehicle?.name ||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
}
};
}
/** True while another expert holds an active workflow lock (uses `expiredAt` when set). */
private isBlameV2WorkflowLockCurrentlyEnforced(doc: {
workflow?: {
locked?: boolean;
lockedAt?: Date;
expiredAt?: Date;
lockedBy?: { actorId?: unknown };
};
}): boolean {
if (!doc?.workflow?.locked) return false;
const exp = doc.workflow.expiredAt;
if (exp) {
return Date.now() < new Date(exp as Date).getTime();
}
const la = doc.workflow.lockedAt;
if (!la) return true;
return (
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
);
}
/**
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
@@ -397,6 +443,13 @@ export class ExpertBlameService {
}
const lockedAt = request.workflow.lockedAt;
const expiredAt = request.workflow.expiredAt;
if (
expiredAt &&
Date.now() < new Date(expiredAt as Date).getTime()
) {
return;
}
if (
lockedAt &&
Date.now() <
@@ -410,7 +463,9 @@ export class ExpertBlameService {
_id: new Types.ObjectId(requestId),
"workflow.locked": true,
};
if (lockedAt) {
if (expiredAt) {
filter["workflow.expiredAt"] = expiredAt;
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
@@ -422,7 +477,11 @@ export class ExpertBlameService {
filter as any,
{
$set: { "workflow.locked": false },
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
},
{ new: false },
);
@@ -432,21 +491,22 @@ export class ExpertBlameService {
}
if (!request.expert?.decision && lockedById) {
const rid = new Types.ObjectId(requestId).toString();
try {
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(String(lockedById)) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: rid } as any,
},
);
await this.recordBlameExpertActivity({
expertId: String(lockedById),
requestId: String(requestId),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:unchecked:expired:${lockedById}`,
});
this.logger.warn(
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
`Blame case ${requestId} workflow lock expired; unlocked in DB and emitted unchecked activity for expert ${lockedById}`,
);
} catch (e) {
this.logger.warn(
`Expert stat rollback failed after blame lock expiry ${requestId}`,
`Expert activity emit failed after blame lock expiry ${requestId}`,
e,
);
}
@@ -471,13 +531,15 @@ export class ExpertBlameService {
if (shouldDecrementChecked) {
updateExp.blameStatus = ReqBlameStatus.UnChecked;
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(r.actorLocked.actorId) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: r._id.toString() },
},
);
await this.recordBlameExpertActivity({
expertId: String(r.actorLocked.actorId),
requestId: String(r._id),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(r.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(r.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${String(r._id)}:unchecked:unlock:${String(r.actorLocked.actorId)}`,
});
this.logger.warn(
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
@@ -527,13 +589,15 @@ export class ExpertBlameService {
if (shouldRollbackStats) {
update.blameStatus = ReqBlameStatus.UnChecked;
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(current.actorLocked.actorId) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: current._id.toString() },
},
);
await this.recordBlameExpertActivity({
expertId: String(current.actorLocked.actorId),
requestId: String(current._id),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(current.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(current.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
});
this.logger.warn(
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
@@ -688,6 +752,7 @@ export class ExpertBlameService {
try {
requireActorClientKey(actor);
const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
@@ -709,6 +774,16 @@ export class ExpertBlameService {
);
}
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined;
const lockerId = String(w?.lockedBy?.actorId ?? "");
if (lockerId && lockerId !== actorId) {
throw new ForbiddenException(
"This request is locked by another expert.",
);
}
}
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
@@ -851,8 +926,16 @@ export class ExpertBlameService {
throw new BadRequestException("Request already locked or invalid status");
}
// Update expert stats atomically (use findOneAndUpdate with conditions)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
// Record expert check activity
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
});
this.scheduleUnlock(updateResult);
return { _id: requestId, lock: true };
@@ -897,29 +980,22 @@ export class ExpertBlameService {
);
}
// Check if locked and not expired
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
} else {
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
}
// Lock expired, allow re-locking (continue below)
// Check if locked and not expired (`expiredAt` or lockedAt + TTL)
if (
request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
) {
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
}
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
@@ -931,6 +1007,9 @@ export class ExpertBlameService {
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": new Date(
now.getTime() + this.blameV2LockTtlMs,
),
"workflow.lockedBy": {
actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert",
@@ -945,8 +1024,15 @@ export class ExpertBlameService {
throw new InternalServerErrorException("Failed to lock the request");
}
// Update expert stats (reusing existing helper)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
});
if (request.type === BlameRequestType.THIRD_PARTY) {
const phones = (request.parties || [])
@@ -958,6 +1044,7 @@ export class ExpertBlameService {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
expertLastName,
},
@@ -1064,7 +1151,15 @@ export class ExpertBlameService {
`partyId ${uid} is not a party userId on this blame request.`,
);
}
for (const item of party.requestedItems || []) {
const normalizedItems = normalizeResendRequestedItemsList(
party.requestedItems as string[],
);
if (normalizedItems.length === 0) {
throw new BadRequestException(
`Party ${uid}: requestedItems must include at least one valid resend item (e.g. drivingLicense, voice, description).`,
);
}
for (const item of normalizedItems) {
if (!allowedItems.has(String(item))) {
throw new BadRequestException(
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
@@ -1078,9 +1173,12 @@ export class ExpertBlameService {
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
const normalizedItems = normalizeResendRequestedItemsList(
party.requestedItems as string[],
);
return {
partyId: new Types.ObjectId(uid),
requestedItems: party.requestedItems,
requestedItems: normalizedItems,
description: party.description || "",
requestedAt: now,
completed: false,
@@ -1103,6 +1201,7 @@ export class ExpertBlameService {
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
@@ -1118,11 +1217,63 @@ export class ExpertBlameService {
);
}
await this.recordBlameExpertActivity({
expertId: String(actorId),
requestId: String(requestId),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
});
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
// TODO: Send notifications to parties (SMS/Push) about required documents
if (request.type === BlameRequestType.THIRD_PARTY) {
const requestIdToken = String(request._id);
const partyPhoneByUserId = new Map<string, string>();
for (const p of request.parties || []) {
const uid = p?.person?.userId ? String(p.person.userId) : "";
const phone = p?.person?.phoneNumber;
if (uid && typeof phone === "string" && phone.length > 0) {
partyPhoneByUserId.set(uid, phone);
}
}
const partyHasResendPayload = (row: {
requestedItems?: readonly string[];
description?: string;
}) => {
const n = Array.isArray(row.requestedItems)
? row.requestedItems.length
: 0;
return n > 0 || String(row.description ?? "").trim().length > 0;
};
for (const p of partyResendRequests) {
if (!partyHasResendPayload(p)) continue;
const uid = String((p as any).partyId);
const phone = partyPhoneByUserId.get(uid);
if (!phone) continue;
const role =
String(
request.parties?.find(
(x: any) => String(x?.person?.userId) === uid,
)?.role,
) === "SECOND"
? "SECOND"
: "FIRST";
await this.smsOrchestrationService.sendResendDocumentsNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
role,
),
});
}
}
return {
requestId: String(request._id),
@@ -1262,6 +1413,7 @@ export class ExpertBlameService {
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
@@ -1277,6 +1429,40 @@ export class ExpertBlameService {
);
}
await this.recordBlameExpertActivity({
expertId: String(actorId),
requestId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:handled:${actorId}`,
});
// THIRD_PARTY: both parties must sign; send each their deep link (mutual-agreement uses yara-blame-agreement elsewhere).
if (request.type === BlameRequestType.THIRD_PARTY) {
const requestIdToken = String(request._id);
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
const parties = request.parties || [];
for (const partyRole of [PartyRole.FIRST, PartyRole.SECOND] as const) {
const p = parties.find((x: any) => x?.role === partyRole);
const phone = p?.person?.phoneNumber;
if (!phone || typeof phone !== "string") continue;
const linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
expertLastName,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
linkRole,
),
});
}
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
@@ -1383,6 +1569,7 @@ export class ExpertBlameService {
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
@@ -1416,65 +1603,32 @@ export class ExpertBlameService {
}
}
private async updateDamageExpertStats(
expertId: string,
requestId: string,
type: "checked" | "handled",
) {
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
console.warn("Invalid expertId, requestId, or type");
private async recordBlameExpertActivity(args: {
expertId: string;
requestId: string;
eventType: ExpertFileActivityType;
tenantId?: string;
idempotencyKey?: string;
}) {
const tenantId =
args.tenantId && Types.ObjectId.isValid(args.tenantId)
? args.tenantId
: undefined;
if (
!Types.ObjectId.isValid(args.expertId) ||
!Types.ObjectId.isValid(args.requestId) ||
!tenantId
) {
return;
}
const expert = await this.expertDbService.findOne({
_id: new Types.ObjectId(expertId),
await this.expertFileActivityDbService.recordEvent({
expertId: args.expertId,
tenantId,
fileId: args.requestId,
fileType: ExpertFileKind.BLAME,
eventType: args.eventType,
idempotencyKey: args.idempotencyKey,
});
if (!expert) {
console.warn("Expert not found:", expertId);
return;
}
const requestIdStr = new Types.ObjectId(requestId).toString();
const countedRequestIds =
expert.countedRequests?.map((id) => id.toString()) || [];
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
console.log(
`Request ${requestIdStr} already checked for expert ${expertId}`,
);
return;
}
const update: any = { $inc: {}, $push: {} };
if (type === "checked") {
update.$inc["requestStats.totalChecked"] = 1;
update.$push["countedRequests"] = requestIdStr;
} else if (type === "handled") {
update.$inc["requestStats.totalHandled"] = 1;
if (countedRequestIds.includes(requestIdStr)) {
update.$inc["requestStats.totalChecked"] = -1;
}
if (!countedRequestIds.includes(requestIdStr)) {
update.$push["countedRequests"] = requestIdStr;
} else {
delete update.$push;
}
}
const updateResult = await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(expertId) },
update,
);
if (!updateResult) {
console.warn("Failed to update expert stats for:", expertId);
} else {
console.log(`Expert stats updated (${type}) for expert:`, expertId);
}
}
async replyRequest(requestId: string, reply: SubmitReplyDto, userId: string) {
@@ -1757,11 +1911,15 @@ export class ExpertBlameService {
},
);
await this.expertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
tenantId:
String(request.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(request.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:handled:inperson:${actorDetail.sub}`,
});
return updated;
}

View File

@@ -28,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
actorId: string;
actorName: string;
lockedAt: string;
expiredAt?: string;
};
@ApiPropertyOptional()
@@ -98,6 +99,16 @@ export class ClaimDetailV2ResponseDto {
damageExpertReplyFinal?: unknown;
};
@ApiPropertyOptional({
description:
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
})
objection?: {
objectionParts?: Array<Record<string, unknown>>;
newParts?: Array<Record<string, unknown>>;
submittedAt?: Date | string;
};
@ApiPropertyOptional({
description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',

View File

@@ -17,10 +17,12 @@ export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
locked: boolean;
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
lockedBy?: {
actorId: string;
actorName: string;
lockedAt?: string;
expiredAt?: string;
};
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })

View File

@@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto {
@ApiPropertyOptional({
type: [String],
enum: ClaimRequiredDocumentType,
description: "Extra document keys to upload (may include types not in the initial claim form).",
description:
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
})
@IsOptional()
@IsArray()

View File

@@ -17,6 +17,7 @@ import {
ApiParam,
ApiQuery,
ApiTags,
ApiOperation,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
export class ExpertClaimController {
constructor(private readonly expertClaimService: ExpertClaimService) {}
@ApiOperation({ deprecated: true })
@Get()
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
}
@ApiOperation({ deprecated: true })
@Get("/:id")
@ApiParam({ name: "id" })
@ApiQuery({
@@ -64,12 +67,14 @@ export class ExpertClaimController {
);
}
@ApiOperation({ deprecated: true })
@Get("imageRequired/:id")
@ApiParam({ name: "id" })
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
return await this.expertClaimService.imageRequired(id, currentUser);
}
@ApiOperation({ deprecated: true })
@Get("required-documents/:id")
@ApiParam({ name: "id" })
async getRequiredDocuments(
@@ -79,11 +84,13 @@ export class ExpertClaimController {
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
}
@ApiOperation({ deprecated: true })
@Put("lock/:id")
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertClaimService.lockClaimRequest(requestId, actor);
}
@ApiOperation({ deprecated: true })
@Put("reply/submit/:id")
@ApiParam({ name: "id" })
async submitReplyRequest(
@@ -98,6 +105,7 @@ export class ExpertClaimController {
);
}
@ApiOperation({ deprecated: true })
@Put("resend/submit/:id")
@ApiParam({ name: "id" })
async submitResend(
@@ -117,6 +125,7 @@ export class ExpertClaimController {
enum: ["car-capture", "accident"],
required: true,
})
@ApiOperation({ deprecated: true })
@Get("stream/:id/video")
@Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4")
@@ -134,6 +143,7 @@ export class ExpertClaimController {
enum: ["car-capture", "accident"],
required: true,
})
@ApiOperation({ deprecated: true })
@Get("link/:id/video")
@ApiParam({ name: "id" })
async getClaimVideoLink(
@@ -143,12 +153,14 @@ export class ExpertClaimController {
return this.expertClaimService.getVideoLink(id, query);
}
@ApiOperation({ deprecated: true })
@Get("stream/:id/voice")
@ApiParam({ name: "id" })
async getClaimVoiceLink(@Param("id") id: string) {
return await this.expertClaimService.getVoiceLink(id);
}
@ApiOperation({ deprecated: true })
@Patch("validate-factors/:claimId")
@ApiParam({ name: "claimId" })
@ApiBody({ type: FactorValidationDto })
@@ -166,11 +178,13 @@ export class ExpertClaimController {
}
@ApiParam({ name: "id" })
@ApiOperation({ deprecated: true })
@Patch(":id/visit")
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
return await this.expertClaimService.inPersonVisit(requestId, actor);
}
@ApiOperation({ deprecated: true })
@Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {

View File

@@ -13,6 +13,7 @@ import { ClientModule } from "src/client/client.module";
import { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
import { RequestManagementModule } from "src/request-management/request-management.module";
import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { UsersModule } from "src/users/users.module";
import { ExpertClaimController } from "./expert-claim.controller";
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
ExpertBlameModule,
AiModule,
RequestManagementModule,
SmsOrchestrationModule,
UsersModule,
ClientModule,
],

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -13,6 +13,7 @@ import {
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertInsurerService } from "./expert-insurer.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
} from "./dto/create-insurer-expert.dto";
@Controller("expert-insurer")
@ApiTags("expert-insurer-panel")
@@ -35,16 +40,59 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
export class ExpertInsurerController {
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
@Get("files")
async getAllFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.retrieveAllFilesOfClient(
@Get("branches")
async getInsuranceBranches(@CurrentUser() insurer) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.retrieveInsuranceBranches(
insurer.clientKey,
);
}
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return await this.expertInsurerService.addBranch(
insurer.clientKey,
createBranchDto,
);
}
@Post("experts/blame")
@ApiBody({ type: CreateBlameExpertByInsurerDto })
async addBlameExpert(
@CurrentUser() insurer,
@Body() body: CreateBlameExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
}
@Post("experts/claim")
@ApiBody({ type: CreateClaimExpertByInsurerDto })
async addClaimExpert(
@CurrentUser() insurer,
@Body() body: CreateClaimExpertByInsurerDto,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
}
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
}
@ApiQuery({ name: "page", type: Number })
@ApiQuery({ name: "response_count", type: Number })
@Get("list")
@Get("experts/list")
async getAllExperts(
@Query("page") page: number,
@Query("response_count") count: number,
@@ -57,48 +105,38 @@ export class ExpertInsurerController {
);
}
@Get("top-experts")
@Get("experts/top")
@ApiOperation({
summary: "Top blame vs claim experts for this insurer",
description:
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
})
async getTopExperts(@CurrentUser() actor) {
return await this.expertInsurerService.getTopExpertsForClient(actor);
}
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
@Get("files")
async getAllFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.retrieveAllFilesOfClient(
insurer.clientKey,
);
}
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
}
@ApiParam({ name: "expertId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId")
async requestDetail(
@Get("files/:publicId")
@ApiParam({ name: "publicId" })
async getFileDetailsByPublicId(
@CurrentUser() insurer,
@Param("expertId") id: string,
@Query("role") role: "claim" | "blame",
@Param("publicId") publicId: string,
) {
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
throw new BadRequestException("Invalid expert ID");
}
if (!["claim", "blame"].includes(role)) {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.getAllFilesByExpertAndRole(
id,
role,
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
insurer.clientKey,
publicId,
);
}
@ApiBody({
description: "Detailed expert rating",
description:
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
schema: {
type: "object",
properties: {
@@ -135,7 +173,7 @@ export class ExpertInsurerController {
minimum: 0,
maximum: 5,
example: 4,
description: "برای امتیاز دادن به بات",
description: "برای امتیاز دادن به عملکرد بات",
},
},
required: [
@@ -154,48 +192,70 @@ export class ExpertInsurerController {
},
},
})
@ApiParam({ name: "requestId" })
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Put("/:requestId/rating")
async rateExperts(
@ApiParam({ name: "publicId" })
@Put("files/:publicId/rating")
async rateExpertsByPublicId(
@CurrentUser() insurer,
@Param("requestId") requestId: string,
@Param("publicId") publicId: string,
@Body() rating: FileRating,
@Query("role") role: "claim" | "blame",
) {
if (!["claim", "blame"].includes(role)) {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.rateExpertOnFile(
requestId,
return await this.expertInsurerService.rateExpertByPublicId(
publicId,
rating,
role,
insurer.clientKey,
);
}
@Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
return await this.expertInsurerService.retrieveInsuranceBranches(
insuranceId,
@Get("top-files")
async getTopFiles(@CurrentUser() insurer) {
return await this.expertInsurerService.getTopFilesForClient(
insurer.clientKey,
);
}
@Post("branches")
@ApiBody({ type: CreateBranchDto })
async addBranch(
@CurrentUser() insurer,
@Body() createBranchDto: CreateBranchDto,
@Get("statistics")
async getExpertStatistics(@CurrentUser() actor) {
return await this.expertInsurerService.getExpertStatisticsReport(actor);
}
@Get("report/status-counts")
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerStatusReport(
@CurrentUser() actor,
@Query("from") from?: string,
@Query("to") to?: string,
) {
if (!insurer) {
throw new UnauthorizedException("Could not identify the current user.");
return await this.expertInsurerService.getInsurerFileStatusCounts(
actor,
from,
to,
);
}
@ApiParam({ name: "expertId" })
@ApiOperation({
summary: "Files handled by one roster expert (summary rows)",
description:
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
})
@Get("/:expertId")
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
if (!Types.ObjectId.isValid(id)) {
throw new BadRequestException("Invalid expert ID");
}
return await this.expertInsurerService.addBranch(
return await this.expertInsurerService.getAllFilesForInsurerExpert(
id,
insurer.clientKey,
createBranchDto,
);
}
}

View File

@@ -19,12 +19,14 @@ import {
BranchModel,
BranchSchema,
} from "src/client/entities/schema/branch.schema";
import { HashModule } from "src/utils/hash/hash.module";
@Module({
imports: [
ClaimRequestManagementModule,
RequestManagementModule,
AuthModule,
HashModule,
UsersModule,
ClientModule,
MongooseModule.forFeature([

View File

@@ -9,6 +9,11 @@ import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
import {
CreateBlameExpertByInsurerDto,
CreateClaimExpertByInsurerDto,
CreateInsurerExpertDto,
} from "./dto/create-insurer-expert.dto";
import {
blameCaseTouchesClient,
claimCaseTouchesClient,
@@ -16,17 +21,25 @@ import {
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import { HashService } from "src/utils/hash/hash.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
@Injectable()
export class ExpertInsurerService {
constructor(
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly branchDbService: BranchDbService,
private readonly hashService: HashService,
) {}
private getClientId(actorOrId: any): Types.ObjectId {
@@ -37,6 +50,53 @@ export class ExpertInsurerService {
return new Types.ObjectId(raw);
}
/**
* Tenant scope on expert / damage-expert: `clientKey` may be stored as ObjectId or
* string (legacy and collection-specific writes). Match both, same as reports service.
*/
private clientKeyScopeFilter(clientObjectId: Types.ObjectId) {
const oid = clientObjectId;
const str = String(oid);
return { $or: [{ clientKey: oid }, { clientKey: str }] };
}
private async buildExpertActivityStatsMap(
tenantId: Types.ObjectId,
expertIds: string[],
): Promise<Record<string, { totalHandled: number; totalChecked: number }>> {
const events = await this.expertFileActivityDbService.findByTenantAndExperts(
tenantId,
expertIds,
);
const stateByExpertFile = new Map<string, { checked: boolean; handled: boolean }>();
for (const e of events) {
const key = `${String(e.expertId)}:${String(e.fileId)}`;
const prev = stateByExpertFile.get(key) ?? { checked: false, handled: false };
if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
}
stateByExpertFile.set(key, prev);
}
const result: Record<string, { totalHandled: number; totalChecked: number }> = {};
for (const expertId of expertIds) {
result[expertId] = { totalHandled: 0, totalChecked: 0 };
}
for (const [key, st] of stateByExpertFile.entries()) {
const [expertId] = key.split(":");
if (!result[expertId]) result[expertId] = { totalHandled: 0, totalChecked: 0 };
if (st.handled) result[expertId].totalHandled += 1;
else if (st.checked) result[expertId].totalChecked += 1;
}
return result;
}
private parseObjectId(value: string, label: string): Types.ObjectId {
if (!Types.ObjectId.isValid(value)) {
throw new BadRequestException(`Invalid ${label}`);
@@ -84,13 +144,99 @@ export class ExpertInsurerService {
};
}
/** BlameCases may tie the field expert via assignment, expert-initiated id, or decision author. */
private blameFieldExpertIdCandidates(b: any): string[] {
const raw: unknown[] = [
b?.expert?.assignedExpertId,
b?.initiatedByFieldExpertId,
b?.expert?.decision?.decidedByExpertId,
];
const out = new Set<string>();
for (const x of raw) {
if (x != null && x !== "") out.add(String(x));
}
return [...out];
}
private claimDamageExpertActorId(c: any): string | null {
const reply =
c?.evaluation?.damageExpertReplyFinal || c?.evaluation?.damageExpertReply;
const aid = reply?.actorDetail?.actorId;
if (aid == null || aid === "") return null;
return String(aid);
}
private mapBlameFileSummaryForInsurerExpert(b: any) {
return {
kind: "blame" as const,
requestId: String(b._id),
publicId: b.publicId,
requestNo: b.requestNo,
type: b.type,
status: b.status,
blameStatus: b.blameStatus,
createdAt: b.createdAt,
updatedAt: b.updatedAt,
};
}
private mapClaimFileSummaryForInsurerExpert(c: any) {
return {
kind: "claim" as const,
requestId: String(c._id),
publicId: c.publicId,
requestNo: c.requestNo ?? c.requestNumber,
status: c.status,
claimStatus: c.claimStatus,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
}
private buildPartiesPreview(parties: any[] | undefined) {
if (!Array.isArray(parties) || !parties.length) return undefined;
return parties.map((p: any) => ({
role: p?.role,
fullName: p?.person?.fullName,
vehicle: {
carName: p?.vehicle?.carName ?? p?.vehicle?.name,
carModel: p?.vehicle?.carModel ?? p?.vehicle?.model,
plate: p?.vehicle?.plate ?? p?.vehicle?.plateId,
},
}));
}
private sanitizeVehicleInquiry(vehicle: any) {
if (!vehicle || typeof vehicle !== "object") return vehicle;
const inquiry = vehicle.inquiry;
if (!inquiry || typeof inquiry !== "object") return vehicle;
const { raw, mapped, ...safeInquiry } = inquiry;
return {
...vehicle,
inquiry: safeInquiry,
};
}
private sanitizePartyForInsurerView(party: any) {
if (!party || typeof party !== "object") return party;
return {
...party,
vehicle: this.sanitizeVehicleInquiry(party.vehicle),
};
}
private getCombinedFileScore(file: any): number | null {
const insurerRating = file?.rating;
const userRating = file?.userRating;
const insurerValues = insurerRating
? Object.values(insurerRating).filter(
(v): v is number => typeof v === "number" && !isNaN(v),
)
? (
[
insurerRating.collisionMethodAccuracy,
insurerRating.evaluationTimeliness,
insurerRating.accidentCauseAccuracy,
insurerRating.guiltyVehicleIdentification,
] as unknown[]
).filter((v): v is number => typeof v === "number" && !isNaN(v))
: [];
const userValues = userRating
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
@@ -115,11 +261,6 @@ export class ExpertInsurerService {
.filter((f) =>
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
)
.filter(
(f) =>
f?.status !== CaseStatus.OPEN &&
f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY,
)
.map((f) => this.normalizeBlameCase(f));
}
@@ -127,20 +268,26 @@ export class ExpertInsurerService {
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
const idStr = String(clientObjectId);
return all
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
.filter((f) => claimCaseTouchesClient(f, idStr))
.map((f) => this.normalizeClaimCase(f));
}
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
const clientObjectId = this.getClientId(actor);
const ckFilter = this.clientKeyScopeFilter(clientObjectId);
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const allExpertsRaw = [...experts, ...damageExperts];
const expertIds = allExpertsRaw.map((e) => String(e._id));
const expertActivityStatsMap = await this.buildExpertActivityStatsMap(
clientObjectId,
expertIds,
);
const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
@@ -155,6 +302,7 @@ export class ExpertInsurerService {
if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) {
if (category === "botRating") continue;
if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) {
expertRatingsByCategoryMap[expertId][category] = [];
@@ -171,7 +319,7 @@ export class ExpertInsurerService {
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
}
const allExperts = allExpertsRaw.map((expert) => {
const mapExpertRow = (expert: any, expertKind: "blame" | "claim") => {
const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length
@@ -192,13 +340,22 @@ export class ExpertInsurerService {
_id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`,
role: expert.role,
expertKind,
type: expert.userType,
requestStats: expert.requestStats,
requestStats: expertActivityStatsMap[expertIdStr] ?? {
totalHandled: 0,
totalChecked: 0,
},
createdAt: expert.createdAt,
overallAverageRating,
averageRatingsByCategory,
};
});
};
const allExperts = [
...experts.map((e) => mapExpertRow(e, "blame")),
...damageExperts.map((e) => mapExpertRow(e, "claim")),
];
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
@@ -212,20 +369,33 @@ export class ExpertInsurerService {
}
/**
* Returns top 10 experts for the current insurer client based on
* combined insurer + user ratings on their handled files.
* Top experts per roster: blame rows come from the `expert` collection (files they
* handled on blame cases); claim rows from `damage-expert` (claim evaluations).
* Scores are aggregated from **file documents** that insurer can see: for each such
* file we take `evaluation.rating` / `expert.rating` (insurer dimensions only,
* excluding `botRating`), optionally blend with the files user rating, average those
* combined scores per file, then average across that experts files (`overallAverageRating`).
*/
async getTopExpertsForClient(actor): Promise<any[]> {
async getTopExpertsForClient(actor): Promise<{
blameExperts: any[];
claimExperts: any[];
}> {
const result = await this.retrieveAllExpertsOfClient(actor, 1, 1000);
const experts = result?.experts || [];
const rows = result?.experts || [];
const sorted = [...experts].sort((a, b) => {
const ar = a.overallAverageRating ?? 0;
const br = b.overallAverageRating ?? 0;
return br - ar;
});
const byRatingDesc = (a: any, b: any) =>
(b.overallAverageRating ?? 0) - (a.overallAverageRating ?? 0);
return sorted.slice(0, 10);
const blameExperts = rows
.filter((e) => e.expertKind === "blame")
.sort(byRatingDesc)
.slice(0, 10);
const claimExperts = rows
.filter((e) => e.expertKind === "claim")
.sort(byRatingDesc)
.slice(0, 10);
return { blameExperts, claimExperts };
}
/**
@@ -244,101 +414,124 @@ export class ExpertInsurerService {
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
}
private async assertExpertBelongsToInsurer(
expertObjectId: Types.ObjectId,
insurerClientKey: string,
) {
const ck = String(insurerClientKey);
const clientOid = new Types.ObjectId(ck);
async getAllFilesForInsurerExpert(expertId: string, insurerClientKey: string) {
const expertObjectId = this.parseObjectId(expertId, "expert id");
const clientOid = this.getClientId(insurerClientKey);
const ckFilter = this.clientKeyScopeFilter(clientOid);
const [experts, damageExperts] = await Promise.all([
this.expertDbService.findAll({ clientKey: ck }),
this.damageExpertDbService.findAll({ clientKey: clientOid }),
this.expertDbService.findAll(ckFilter as never),
this.damageExpertDbService.findAll(ckFilter as never),
]);
const id = String(expertObjectId);
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
if (!ok) {
const onBlameRoster = experts.some((e) => String(e._id) === id);
const onClaimRoster = damageExperts.some((e) => String(e._id) === id);
if (!onBlameRoster && !onClaimRoster) {
throw new ForbiddenException(
"This expert is not registered under your insurance company.",
);
}
}
async getAllFilesByExpertAndRole(
expertId: string,
role: "claim" | "blame",
insurerClientKey: string,
) {
const expertObjectId = this.parseObjectId(expertId, "expert id");
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
const clientKeyStr = String(this.getClientId(insurerClientKey));
if (role === "claim") {
const clientKeyStr = String(clientOid);
if (onBlameRoster) {
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
return blames
.filter(
(b) =>
blameCaseTouchesClient(b, clientKeyStr) &&
this.blameFieldExpertIdCandidates(b).includes(id),
)
.map((b) => this.mapBlameFileSummaryForInsurerExpert(b));
}
if (onClaimRoster) {
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
return claims
.filter(
(c) =>
claimCaseTouchesClient(c, clientKeyStr) &&
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
String(expertObjectId),
this.claimDamageExpertActorId(c) === id,
)
.map((c) => this.normalizeClaimCase(c));
.map((c) => this.mapClaimFileSummaryForInsurerExpert(c));
}
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
return blames
.filter(
(b) =>
blameCaseTouchesClient(b, clientKeyStr) &&
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
)
.map((b) => this.normalizeBlameCase(b));
return [];
}
async rateExpertOnFile(
requestId: string,
rating: FileRating,
role: "claim" | "blame",
insurerClientKey: string,
) {
const _id = this.parseObjectId(requestId, "request id");
const clientKeyStr = String(this.getClientId(insurerClientKey));
for (const [key, value] of Object.entries(rating || {})) {
private validateInsurerFileRating(rating: FileRating) {
const required: (keyof FileRating)[] = [
"collisionMethodAccuracy",
"evaluationTimeliness",
"accidentCauseAccuracy",
"guiltyVehicleIdentification",
"botRating",
];
for (const key of required) {
const value = rating?.[key];
if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException(`${key} must be a number between 0 and 5`);
}
}
if (role === "claim") {
const existing = await this.claimCaseDbService.findById(_id);
if (!existing) throw new NotFoundException("Claim file not found");
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
throw new ForbiddenException("This claim does not belong to your organization.");
}
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
}
/**
* Applies one insurer rating to every underlying case (claim and/or blame) for
* the shared `publicId`, when that case exists and belongs to this insurer.
*/
async rateExpertByPublicId(
publicId: string,
rating: FileRating,
insurerClientKey: string,
) {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
this.validateInsurerFileRating(rating);
const id = this.getClientId(insurerClientKey);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
const out: {
publicId: string;
claim?: { requestId: string; updatedRating: FileRating };
blame?: { requestId: string; updatedRating: FileRating };
} = { publicId };
if (claim) {
const claimId = this.parseObjectId(String((claim as any)._id), "claim id");
const updated = await this.claimCaseDbService.findByIdAndUpdate(claimId, {
$set: { "evaluation.rating": rating },
});
if (!updated) throw new NotFoundException("Claim file not found");
return {
message: "Claim expert rated successfully",
updatedRating: (updated as any)?.evaluation?.rating || rating,
requestId: updated._id,
out.claim = {
requestId: String((updated as any)._id),
updatedRating: (updated as any)?.evaluation?.rating ?? rating,
};
}
if (role === "blame") {
const existing = await this.blameRequestDbService.findById(_id);
if (!existing) throw new NotFoundException("Blame file not found");
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
throw new ForbiddenException("This blame case does not belong to your organization.");
}
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
if (blame) {
const blameId = this.parseObjectId(String((blame as any)._id), "blame id");
const updated = await this.blameRequestDbService.findByIdAndUpdate(blameId, {
$set: { "expert.rating": rating },
});
if (!updated) throw new NotFoundException("Blame file not found");
return {
message: "Blame expert rated successfully",
updatedRating: (updated as any)?.expert?.rating || rating,
requestId: updated._id,
out.blame = {
requestId: String((updated as any)._id),
updatedRating: (updated as any)?.expert?.rating ?? rating,
};
}
throw new BadRequestException("Invalid role");
return {
message: "Rating saved for this file",
...out,
};
}
async retrieveAllFilesOfClient(insurerId: string) {
@@ -347,7 +540,245 @@ export class ExpertInsurerService {
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
return { blameFiles, claimFiles };
const byPublicId = new Map<
string,
{
publicId: string;
fileType?: string;
parties?: Array<{
role?: string;
fullName?: string;
vehicle?: {
carName?: string;
carModel?: string;
plate?: string;
};
}>;
blame?: {
requestId?: string;
requestNo?: string;
status?: string;
parties?: Array<{
role?: string;
fullName?: string;
vehicle?: {
carName?: string;
carModel?: string;
plate?: string;
};
}>;
createdAt?: Date | string;
updatedAt?: Date | string;
};
claim?: {
requestId?: string;
requestNo?: string;
status?: string;
claimStatus?: string;
currentStep?: string;
createdAt?: Date | string;
updatedAt?: Date | string;
};
createdAt?: Date | string;
updatedAt?: Date | string;
}
>();
for (const b of blameFiles) {
const key = String((b as any).publicId || (b as any)._id || "");
if (!key) continue;
const prev = byPublicId.get(key) ?? { publicId: key };
const partiesPreview = this.buildPartiesPreview((b as any).parties);
prev.fileType = prev.fileType ?? (b as any).type;
prev.blame = {
requestId: (b as any)?._id?.toString?.() || String((b as any)?._id || ""),
requestNo: (b as any).requestNo || String((b as any).requestNumber || ""),
status: (b as any).status,
parties: partiesPreview,
createdAt: (b as any).createdAt,
updatedAt: (b as any).updatedAt,
};
prev.parties = prev.parties ?? partiesPreview;
prev.createdAt = prev.createdAt ?? (b as any).createdAt;
prev.updatedAt = (b as any).updatedAt ?? prev.updatedAt;
byPublicId.set(key, prev);
}
for (const c of claimFiles) {
const key = String((c as any).publicId || (c as any)._id || "");
if (!key) continue;
const prev = byPublicId.get(key) ?? { publicId: key };
const claimPartiesPreview = this.buildPartiesPreview((c as any)?.snapshot?.parties);
prev.fileType = prev.fileType ?? (c as any)?.snapshot?.accident?.type;
prev.claim = {
requestId: (c as any)?._id?.toString?.() || String((c as any)?._id || ""),
requestNo: (c as any).requestNo || String((c as any).requestNumber || ""),
status: (c as any).status,
claimStatus: (c as any).claimStatus,
currentStep: (c as any).currentStep,
createdAt: (c as any).createdAt,
updatedAt: (c as any).updatedAt,
};
prev.parties = prev.parties ?? claimPartiesPreview;
prev.createdAt = prev.createdAt ?? (c as any).createdAt;
prev.updatedAt = (c as any).updatedAt ?? prev.updatedAt;
byPublicId.set(key, prev);
}
const files = Array.from(byPublicId.values())
.map((f) => ({
publicId: f.publicId,
fileType: f.fileType,
hasClaim: !!f.claim,
parties: f.parties,
blame: f.blame,
claim: f.claim,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
}))
.sort((a, b) => {
const at = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bt = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bt - at;
});
return {
total: files.length,
files,
};
}
async retrieveFileDetailsByPublicId(insurerId: string, publicId: string) {
if (!publicId?.trim()) {
throw new BadRequestException("publicId is required");
}
const id = this.getClientId(insurerId);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
const blame = blameFiles.find((b) => String((b as any).publicId) === publicId);
const claim = claimFiles.find((c) => String((c as any).publicId) === publicId);
if (!blame && !claim) {
throw new NotFoundException("File not found for this publicId");
}
const parties = Array.isArray((blame as any)?.parties) ? (blame as any).parties : [];
const firstParty = parties.find((p: any) => p?.role === "FIRST");
const secondParty = parties.find((p: any) => p?.role === "SECOND");
const claimSnapshotParties = Array.isArray((claim as any)?.snapshot?.parties)
? (claim as any).snapshot.parties
: undefined;
const overview = {
publicId,
requestNo:
(blame as any)?.requestNo ||
(claim as any)?.requestNo ||
(blame as any)?.requestNumber ||
(claim as any)?.requestNumber,
type: (blame as any)?.type,
hasClaim: !!claim,
blameStatus: (blame as any)?.status,
claimStatus: (claim as any)?.status,
claimReviewStatus: (claim as any)?.claimStatus,
createdAt: (blame as any)?.createdAt ?? (claim as any)?.createdAt,
updatedAt: (claim as any)?.updatedAt ?? (blame as any)?.updatedAt,
};
const blameDetails = blame
? {
requestId:
(blame as any)?._id?.toString?.() || String((blame as any)?._id || ""),
requestNo:
(blame as any).requestNo || String((blame as any).requestNumber || ""),
status: (blame as any).status,
blameStatus: (blame as any).blameStatus,
workflow: (blame as any).workflow,
parties: {
first: firstParty
? {
fullName: firstParty?.person?.fullName,
phoneNumber: firstParty?.person?.phoneNumber,
role: firstParty?.role,
vehicle: this.sanitizeVehicleInquiry(firstParty?.vehicle),
}
: undefined,
second: secondParty
? {
fullName: secondParty?.person?.fullName,
phoneNumber: secondParty?.person?.phoneNumber,
role: secondParty?.role,
vehicle: this.sanitizeVehicleInquiry(secondParty?.vehicle),
}
: undefined,
},
expert: (blame as any).expert
? {
assignedExpertId: (blame as any).expert?.assignedExpertId,
decision: (blame as any).expert?.decision,
resend: (blame as any).expert?.resend,
rating: (blame as any).expert?.rating,
}
: undefined,
createdAt: (blame as any).createdAt,
updatedAt: (blame as any).updatedAt,
}
: undefined;
// Keep claim section claim-specific to avoid duplicating shared data already in blame/overview.
const claimDetails = claim
? {
requestId:
(claim as any)?._id?.toString?.() || String((claim as any)?._id || ""),
requestNo:
(claim as any).requestNo || String((claim as any).requestNumber || ""),
status: (claim as any).status,
claimStatus: (claim as any).claimStatus,
owner: (claim as any).owner,
vehicle: (claim as any).vehicle,
money: (claim as any).money,
damage: (claim as any).damage,
media: (claim as any).media,
requiredDocuments: (claim as any).requiredDocuments,
snapshot: (claim as any).snapshot
? {
...(claim as any).snapshot,
parties: claimSnapshotParties?.map((p: any) =>
this.sanitizePartyForInsurerView(p),
),
}
: undefined,
workflow: {
currentStep: (claim as any).currentStep,
nextStep: (claim as any).nextStep,
},
evaluation: {
damageExpertReply: (claim as any).damageExpertReply,
damageExpertReplyFinal: (claim as any).damageExpertReplyFinal,
damageExpertResend: (claim as any).damageExpertResend,
objection: (claim as any).objection,
priceDrop: (claim as any).priceDrop,
visitLocation: (claim as any).visitLocation,
factors: (claim as any).evaluation?.factors,
},
userResendDocuments: (claim as any).userResendDocuments,
userRating: (claim as any).userRating,
insurerRating: (claim as any).rating,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
}
: undefined;
return {
overview,
blame: blameDetails,
claim: claimDetails,
};
}
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
@@ -372,6 +803,112 @@ export class ExpertInsurerService {
return newBranch;
}
private async assertBranchBelongsToClient(
branchId: string,
clientId: Types.ObjectId,
) {
const branch = await this.branchDbService.findById(branchId);
if (!branch) {
throw new NotFoundException("Branch not found");
}
if (String(branch.clientKey) !== String(clientId)) {
throw new ForbiddenException(
"Selected branch does not belong to your insurance company.",
);
}
return branch;
}
private sanitizeExpertResponse(doc: any) {
const obj = typeof doc?.toObject === "function" ? doc.toObject() : doc;
if (!obj) return obj;
const { password, otp, ...rest } = obj;
return rest;
}
private async createExpertForInsurer(
insurerClientKey: string,
payload: CreateInsurerExpertDto,
role: RoleEnum.EXPERT | RoleEnum.DAMAGE_EXPERT,
) {
const clientObjectId = this.getClientId(insurerClientKey);
const branch = await this.assertBranchBelongsToClient(
payload.branchId,
clientObjectId,
);
const email = payload.email.trim().toLowerCase();
const existingByEmail = await this.expertDbService.findOne({ email });
const existingDamageByEmail = await this.damageExpertDbService.findOne({ email });
if (existingByEmail || existingDamageByEmail) {
throw new ConflictException("An expert with this email already exists.");
}
const nationalCode = payload.nationalCode.trim();
const existingByNational = await this.expertDbService.findOne({ nationalCode });
const existingDamageByNational = await this.damageExpertDbService.findOne({
nationalCode,
});
if (existingByNational || existingDamageByNational) {
throw new ConflictException(
"An expert with this national code already exists.",
);
}
const hashedPassword = await this.hashService.hash(payload.password);
const basePayload: any = {
...payload,
email,
nationalCode,
password: hashedPassword,
role,
userType: payload.userType ?? UserType.LEGAL,
clientKey:
role === RoleEnum.EXPERT ? String(clientObjectId) : clientObjectId,
branchId: new Types.ObjectId(payload.branchId),
insuActivityCo: String(clientObjectId),
};
const created =
role === RoleEnum.EXPERT
? await this.expertDbService.create(basePayload)
: await this.damageExpertDbService.create(basePayload);
return {
expert: this.sanitizeExpertResponse(created),
branch: {
_id: (branch as any)._id,
name: branch.name,
code: branch.code,
city: branch.city,
state: branch.state,
address: branch.address,
},
};
}
async addBlameExpert(
insurerClientKey: string,
payload: CreateBlameExpertByInsurerDto,
) {
return this.createExpertForInsurer(
insurerClientKey,
payload,
RoleEnum.EXPERT,
);
}
async addClaimExpert(
insurerClientKey: string,
payload: CreateClaimExpertByInsurerDto,
) {
return this.createExpertForInsurer(
insurerClientKey,
payload,
RoleEnum.DAMAGE_EXPERT,
);
}
/**
* Get comprehensive statistics for all experts of a client
* Returns:
@@ -501,4 +1038,113 @@ export class ExpertInsurerService {
async retrieveInsuranceBranches(insuranceId: string) {
return this.branchDbService.findAll(insuranceId);
}
private parseDateRange(from?: string, to?: string) {
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
if (fromDate && Number.isNaN(fromDate.getTime())) {
throw new BadRequestException("Invalid 'from' date");
}
if (toDate && Number.isNaN(toDate.getTime())) {
throw new BadRequestException("Invalid 'to' date");
}
if (fromDate && toDate && fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return { fromDate, toDate };
}
private isInDateRange(value: any, fromDate?: Date, toDate?: Date): boolean {
if (!fromDate && !toDate) return true;
if (!value) return false;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return false;
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
}
async getInsurerFileStatusCounts(
actor: any,
from?: string,
to?: string,
): Promise<{
all: number;
completed: number;
under_review: number;
waiting_for_documents_resend: number;
}> {
const clientObjectId = this.getClientId(actor);
const { fromDate, toDate } = this.parseDateRange(from, to);
const [blameFilesRaw, claimFilesRaw] = await Promise.all([
this.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const blameFiles = blameFilesRaw.filter((f) =>
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
);
const claimFiles = claimFilesRaw.filter((f) =>
this.isInDateRange((f as any)?.createdAt, fromDate, toDate),
);
const fileMap = new Map<
string,
{ blame?: any; claim?: any }
>();
for (const b of blameFiles) {
const key = String((b as any).publicId || (b as any)._id || "");
if (!key) continue;
const prev = fileMap.get(key) ?? {};
prev.blame = b;
fileMap.set(key, prev);
}
for (const c of claimFiles) {
const key = String((c as any).publicId || (c as any)._id || "");
if (!key) continue;
const prev = fileMap.get(key) ?? {};
prev.claim = c;
fileMap.set(key, prev);
}
let completed = 0;
let underReview = 0;
let waitingForDocumentsResend = 0;
for (const entry of fileMap.values()) {
const blameStatus = entry.blame?.status;
const claimStatus = entry.claim?.status;
if (claimStatus === ClaimCaseStatus.COMPLETED) {
completed += 1;
}
const blameUnderReview =
blameStatus === CaseStatus.WAITING_FOR_EXPERT;
const claimUnderReview =
claimStatus === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
claimStatus === ClaimCaseStatus.EXPERT_REVIEWING;
if (blameUnderReview || claimUnderReview) {
underReview += 1;
}
const blameResend =
blameStatus === CaseStatus.WAITING_FOR_DOCUMENT_RESEND;
const claimResend =
claimStatus === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
if (blameResend || claimResend) {
waitingForDocumentsResend += 1;
}
}
return {
all: fileMap.size,
completed,
under_review: underReview,
waiting_for_documents_resend: waitingForDocumentsResend,
};
}
}

View File

@@ -1,4 +0,0 @@
export enum PanelModels {
BLAME = "blame",
CLAIM = "claim",
}

View 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;
}

View File

@@ -1,5 +1,6 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export function resendRequestHasPayload(r: {
resendDescription?: string;
@@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: {
return false;
}
/** Normalize expert-requested document keys (strings or { key }). */
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
if (!Array.isArray(docs)) return [];
const keys: string[] = [];
for (const d of docs) {
if (typeof d === "string" && d.trim()) keys.push(d.trim());
else if (d && typeof d === "object" && "key" in (d as object)) {
if (typeof d === "string" && d.trim()) {
const c = canonicalizeResendDocumentKey(d.trim());
if (c) keys.push(c);
} else if (d && typeof d === "object" && "key" in (d as object)) {
const k = String((d as { key?: string }).key || "").trim();
if (k) keys.push(k);
const c = canonicalizeResendDocumentKey(k);
if (c) keys.push(c);
}
}
return keys;
return [...new Set(keys)];
}
/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
if (!Array.isArray(parts)) return [];
const keys: string[] = [];
@@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend(
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
return allowed.has(documentKey);
const normalized =
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
return allowed.has(normalized);
}
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {

View 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;
}

View File

@@ -32,13 +32,27 @@ async function bootstrap() {
.setVersion("1.0.0")
.addServer(process.env.URL)
.addServer(process.env.BASE_URL + "/api")
.addServer("http://192.168.20.249:9001")
.addServer("http://192.168.20.170:9001")
.addServer("http://localhost:9001")
.addBearerAuth()
.build();
const docs = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/docs", app, docs);
SwaggerModule.setup("/docs", app, docs, {
swaggerOptions: {
persistAuthorization: true,
docExpansion: "none",
tagsSorter: (a: string, b: string) => {
const priority: Record<string, number> = {
user: 0,
actor: 1,
};
const pa = priority[a] ?? 100;
const pb = priority[b] ?? 100;
if (pa !== pb) return pa - pb;
return a.localeCompare(b);
},
},
});
await app.listen(process.env.PORT);
}

View File

@@ -47,10 +47,6 @@ export class ProfileController {
@UseGuards(GlobalGuard)
@ApiBody({ type: AddPlateProfileDto })
async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) {
console.log(
"🚀 ~ file: profile.controller.ts:50 ~ ProfileController ~ addPlate ~ user:",
user,
);
return await this.plateService.addPlate(user.sub, body);
}

View File

@@ -1,29 +1,99 @@
/**
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
* plus `all` = total matching documents for the tenant.
* V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
* `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
*/
export class BlameCaseStatusCountReportDtoRs {
[key: string]: number;
constructor(report: Record<string, number>) {
Object.assign(this, report);
}
}
export class ClaimCaseStatusCountReportDtoRs {
[key: string]: number;
constructor(report: Record<string, number>) {
Object.assign(this, report);
}
}
export class CompanyAllRequestsCountReportDtoRs {
blame: Record<string, number>;
export class InsurerRequestsSummaryDtoRs {
claim: Record<string, number>;
blame: Record<string, number>;
unifiedByPublicId: { total: number };
constructor(blame: Record<string, number>, claim: Record<string, number>) {
this.blame = blame;
constructor(
claim: Record<string, number>,
blame: Record<string, number>,
unifiedTotal: number,
) {
this.claim = claim;
this.blame = blame;
this.unifiedByPublicId = { total: unifiedTotal };
}
}
export class InsurerPerMonthReportRowDtoRs {
stDate: Date;
enDate: Date;
faLabel: string;
data: InsurerRequestsSummaryDtoRs;
constructor(
stDate: Date,
enDate: Date,
faLabel: string,
data: InsurerRequestsSummaryDtoRs,
) {
this.stDate = stDate;
this.enDate = enDate;
this.faLabel = faLabel;
this.data = data;
}
}
/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */
export class InsurerExpertWorkLogEntryDtoRs {
expertId: string;
fullName: string;
/**
* `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`).
* `damage_expert` = claim damage expert (`damage-expert` collection).
* Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here.
*/
expertKind: "expert" | "damage_expert";
/** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */
totalHandled: number;
/** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */
currentlyChecking: number;
/** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */
distinctFilesCheckedInPeriod: number;
constructor(
expertId: string,
fullName: string,
expertKind: "expert" | "damage_expert",
totalHandled: number,
currentlyChecking: number,
distinctFilesCheckedInPeriod: number,
) {
this.expertId = expertId;
this.fullName = fullName;
this.expertKind = expertKind;
this.totalHandled = totalHandled;
this.currentlyChecking = currentlyChecking;
this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod;
}
}
export class InsurerExpertWorkLogResponseDtoRs {
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) {
this.experts = experts;
}
}
export class InsurerExpertWorkLogPerMonthRowDtoRs {
stDate: Date;
enDate: Date;
faLabel: string;
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(
stDate: Date,
enDate: Date,
faLabel: string,
experts: InsurerExpertWorkLogEntryDtoRs[],
) {
this.stDate = stDate;
this.enDate = enDate;
this.faLabel = faLabel;
this.experts = experts;
}
}

View File

@@ -1,8 +1,12 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { ClientKey } from "src/decorators/clientKey.decorator";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
@ApiTags("reports")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY)
@Roles(RoleEnum.COMPANY)
@Controller("reports")
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get("/report/requests")
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) {
return await this.reportsService.getAllRequestsReportCount(actor, client);
@Get("report/insurer/requests")
@ApiOperation({
summary: "Insurer: claim/blame status buckets + unified file count",
description:
"Claim and blame counts use the same V2 bucket rules as expert panels (IN_PROGRESS grouping), scoped to the insurer JWT tenant. unifiedByPublicId.total counts distinct publicId values like GET expert-insurer/files.",
})
async getInsurerRequestsSummary(@CurrentUser() actor) {
return await this.reportsService.getInsurerRequestsSummary(actor);
}
@Get("/report/perMonthRequests")
async getAllRequestsReportPerMonth(
@Get("report/insurer/per-month-requests")
@ApiOperation({
summary: "Insurer: same summary as report/insurer/requests, per calendar month",
description:
"Last five calendar months; each slice filters blame/claim rows by createdAt in that month, then claim buckets, blame buckets, and unified publicId count.",
})
async getInsurerRequestsSummaryPerMonth(@CurrentUser() actor) {
return await this.reportsService.getInsurerRequestsSummaryPerMonth(actor);
}
@Get("report/insurer/checked-requests")
@ApiOperation({
summary: "Insurer: summary filtered by optional createdAt range",
description:
"Same payload as report/insurer/requests. When from/to are omitted, all tenant files are included. When provided, only blame/claim rows whose createdAt falls in the range are counted (inclusive).",
})
@ApiQuery({
name: "from",
required: false,
description: "Optional start datetime (ISO string)",
})
@ApiQuery({
name: "to",
required: false,
description: "Optional end datetime (ISO string)",
})
async getInsurerRequestsSummaryByDateRange(
@CurrentUser() actor,
@ClientKey() client,
@Query("from") from?: string,
@Query("to") to?: string,
) {
return await this.reportsService.getAllRequestsReportPerMonth(
return await this.reportsService.getInsurerRequestsSummaryByDateRange(
actor,
client,
from,
to,
);
}
@Get("/report/checkedRequests")
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) {
return await this.reportsService.getAllCheckedRequestsCount(actor, client);
@Get("report/insurer/expert-work-log")
@ApiOperation({
summary: "Insurer: per-expert work log (blame experts + damage experts)",
description:
"Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.",
})
async getInsurerExpertWorkLog(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLog(actor);
}
@Get("report/insurer/expert-work-log/per-month")
@ApiOperation({
summary: "Insurer: blame + damage expert work log, last five calendar months",
description:
"Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that months end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).",
})
async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor);
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { RequestManagementModule } from "src/request-management/request-management.module";
import { UsersModule } from "src/users/users.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service";
imports: [
RequestManagementModule,
ClaimRequestManagementModule,
UsersModule,
],
controllers: [ReportsController],
providers: [ReportsService],

View File

@@ -1,197 +1,199 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { requireActorClientKey } from "src/helpers/tenant-scope";
import {
BlameCaseStatusCountReportDtoRs,
ClaimCaseStatusCountReportDtoRs,
CompanyAllRequestsCountReportDtoRs,
blameCaseStatusToReportBucket,
claimCaseStatusToReportBucket,
initialBlameExpertReportBuckets,
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import {
blameCaseTouchesClient,
claimCaseTouchesClient,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import {
InsurerExpertWorkLogPerMonthRowDtoRs,
InsurerExpertWorkLogResponseDtoRs,
InsurerExpertWorkLogEntryDtoRs,
InsurerPerMonthReportRowDtoRs,
InsurerRequestsSummaryDtoRs,
} from "./dto/reports.dto";
type NormalizedActivity = {
expertId: string;
fileId: string;
eventType: ExpertFileActivityType;
occurredAt: Date;
};
@Injectable()
export class ReportsService {
constructor(
private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) {}
private clientObjectId(client: string | undefined): Types.ObjectId {
const ck = requireActorClientKey({ clientKey: client });
return new Types.ObjectId(ck);
private parseDateRange(from?: string, to?: string) {
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
if (fromDate && Number.isNaN(fromDate.getTime())) {
throw new BadRequestException("Invalid 'from' date");
}
if (toDate && Number.isNaN(toDate.getTime())) {
throw new BadRequestException("Invalid 'to' date");
}
if (fromDate && toDate && fromDate > toDate) {
throw new BadRequestException("'from' must be before or equal to 'to'");
}
return { fromDate, toDate };
}
private isBlameForClient(r: any, client: Types.ObjectId): boolean {
const idStr = String(client);
return (r?.parties || []).some(
(p) => String(p?.person?.clientId || "") === idStr,
);
private isInDateRange(
value: unknown,
fromDate?: Date,
toDate?: Date,
): boolean {
if (!fromDate && !toDate) return true;
if (!value) return false;
const d = new Date(value as string | Date);
if (Number.isNaN(d.getTime())) return false;
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
}
private isClaimForClient(r: any, client: Types.ObjectId): boolean {
return String(r?.owner?.userClientKey || "") === String(client);
private publicIdKey(doc: Record<string, unknown>): string {
return String(doc.publicId ?? doc._id ?? "");
}
private countBlameByCaseStatus(
blames: any[],
clientId: Types.ObjectId,
private buildClaimBucketsForInsurer(
rows: Record<string, unknown>[],
clientKey: string,
dateFrom?: Date,
dateTo?: Date,
): Record<string, number> {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) {
out[s] = 0;
const buckets = initialClaimExpertReportBuckets();
for (const doc of rows) {
if (!claimCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const st = String(doc.status ?? "");
const key = claimCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
for (const r of blames) {
if (!this.isBlameForClient(r, clientId)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
return buckets;
}
private countClaimByCaseStatus(
claims: any[],
clientId: Types.ObjectId,
private buildBlameBucketsForInsurer(
rows: Record<string, unknown>[],
clientKey: string,
dateFrom?: Date,
dateTo?: Date,
): Record<string, number> {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(ClaimCaseStatus)) {
out[s] = 0;
const buckets = initialBlameExpertReportBuckets();
for (const doc of rows) {
if (!blameCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const st = String(doc.status ?? "");
const key = blameCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
for (const r of claims) {
if (!this.isClaimForClient(r, clientId)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
}
async getAllRequestsCountByRole(role: string, client: string | undefined) {
const clientId = this.clientObjectId(client);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
if (role === "expert") {
return this.countBlameByCaseStatus(blames as any[], clientId);
}
if (role === "damage_expert") {
return this.countClaimByCaseStatus(claims as any[], clientId);
}
if (role === "company") {
return {
blame: this.countBlameByCaseStatus(blames as any[], clientId),
claim: this.countClaimByCaseStatus(claims as any[], clientId),
};
}
return null;
return buckets;
}
/**
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
* Company gets both blame and claim maps; expert / damage_expert get one map.
* One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
*/
async getDateFilteredRequestByStatus(
role: string,
client: string | undefined,
start: Date,
end: Date,
) {
const clientId = this.clientObjectId(client);
private countUnifiedByPublicId(
blameRows: Record<string, unknown>[],
claimRows: Record<string, unknown>[],
clientKey: string,
dateFrom?: Date,
dateTo?: Date,
): number {
const keys = new Set<string>();
for (const doc of blameRows) {
if (!blameCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const k = this.publicIdKey(doc);
if (k) keys.add(k);
}
for (const doc of claimRows) {
if (!claimCaseTouchesClient(doc, clientKey)) continue;
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
const k = this.publicIdKey(doc);
if (k) keys.add(k);
}
return keys.size;
}
private buildInsurerSummary(
blames: Record<string, unknown>[],
claims: Record<string, unknown>[],
clientKey: string,
dateFrom?: Date,
dateTo?: Date,
): InsurerRequestsSummaryDtoRs {
const claim = this.buildClaimBucketsForInsurer(
claims,
clientKey,
dateFrom,
dateTo,
);
const blame = this.buildBlameBucketsForInsurer(
blames,
clientKey,
dateFrom,
dateTo,
);
const unifiedTotal = this.countUnifiedByPublicId(
blames,
claims,
clientKey,
dateFrom,
dateTo,
);
return new InsurerRequestsSummaryDtoRs(claim, blame, unifiedTotal);
}
async getInsurerRequestsSummary(actor: {
clientKey?: string;
}): Promise<InsurerRequestsSummaryDtoRs> {
const clientKey = requireActorClientKey(actor);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
const inRange = (r: any) => {
const createdAt = new Date(r.createdAt);
return createdAt >= start && createdAt <= end;
};
if (role === "company") {
const blameOut: Record<string, number> = { all: 0 };
const claimOut: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) blameOut[s] = 0;
for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0;
for (const r of blames as any[]) {
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in blameOut) blameOut[st]++;
else blameOut[st] = (blameOut[st] ?? 0) + 1;
blameOut.all++;
}
for (const r of claims as any[]) {
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in claimOut) claimOut[st]++;
else claimOut[st] = (claimOut[st] ?? 0) + 1;
claimOut.all++;
}
return { blame: blameOut, claim: claimOut };
}
if (role === "expert") {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) out[s] = 0;
for (const r of blames as any[]) {
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
}
if (role === "damage_expert") {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(ClaimCaseStatus)) out[s] = 0;
for (const r of claims as any[]) {
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
const st = r?.status as string;
if (st in out) out[st]++;
else out[st] = (out[st] ?? 0) + 1;
out.all++;
}
return out;
}
throw new BadRequestException("Unsupported role for reports");
}
async getAllRequestsReportCount(actor: any, client: string | undefined) {
requireActorClientKey(actor);
if (actor.role === "damage_expert") {
const data = await this.getAllRequestsCountByRole(actor.role, client);
return new ClaimCaseStatusCountReportDtoRs(data as Record<string, number>);
}
if (actor.role === "expert") {
const data = await this.getAllRequestsCountByRole(actor.role, client);
return new BlameCaseStatusCountReportDtoRs(data as Record<string, number>);
}
const companyPayload = await this.getAllRequestsCountByRole(
"company",
client,
);
return new CompanyAllRequestsCountReportDtoRs(
(companyPayload as { blame: Record<string, number> }).blame,
(companyPayload as { claim: Record<string, number> }).claim,
return this.buildInsurerSummary(
blames as Record<string, unknown>[],
claims as Record<string, unknown>[],
clientKey,
);
}
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
requireActorClientKey(actor);
const result: any[] = [];
async getInsurerRequestsSummaryPerMonth(actor: {
clientKey?: string;
}): Promise<InsurerPerMonthReportRowDtoRs[]> {
const clientKey = requireActorClientKey(actor);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
const blameRows = blames as Record<string, unknown>[];
const claimRows = claims as Record<string, unknown>[];
const result: InsurerPerMonthReportRowDtoRs[] = [];
const now = new Date();
for (let i = 0; i < 5; i++) {
@@ -209,35 +211,286 @@ export class ReportsService {
year: "numeric",
});
let data: any;
if (actor.role === "company") {
data = await this.getDateFilteredRequestByStatus(
"company",
client,
monthStart,
monthEnd,
);
} else {
data = await this.getDateFilteredRequestByStatus(
actor.role,
client,
monthStart,
monthEnd,
);
}
const data = this.buildInsurerSummary(
blameRows,
claimRows,
clientKey,
monthStart,
monthEnd,
);
result.unshift({
stDate: monthStart,
enDate: monthEnd,
faLabel: label,
data,
});
result.unshift(
new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
);
}
return result;
}
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
return this.getAllRequestsReportCount(actor, client);
async getInsurerRequestsSummaryByDateRange(
actor: { clientKey?: string },
from?: string,
to?: string,
): Promise<InsurerRequestsSummaryDtoRs> {
const clientKey = requireActorClientKey(actor);
const { fromDate, toDate } = this.parseDateRange(from, to);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
return this.buildInsurerSummary(
blames as Record<string, unknown>[],
claims as Record<string, unknown>[],
clientKey,
fromDate,
toDate,
);
}
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
private replayExpertFileStates(
events: NormalizedActivity[],
cutoff: Date,
): Map<string, { checked: boolean; handled: boolean }> {
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
const filtered = events.filter((e) => e.occurredAt <= cutoff);
filtered.sort((a, b) => {
const t = a.occurredAt.getTime() - b.occurredAt.getTime();
if (t !== 0) return t;
return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`);
});
for (const e of filtered) {
const key = `${e.expertId}:${e.fileId}`;
const prev = stateByKey.get(key) ?? { checked: false, handled: false };
if (e.eventType === ExpertFileActivityType.CHECKED) {
if (!prev.handled) prev.checked = true;
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
prev.checked = false;
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
prev.handled = true;
prev.checked = false;
}
stateByKey.set(key, prev);
}
return stateByKey;
}
private snapshotCountsForExpert(
stateByKey: Map<string, { checked: boolean; handled: boolean }>,
expertId: string,
): { totalHandled: number; currentlyChecking: number } {
let totalHandled = 0;
let currentlyChecking = 0;
const prefix = `${expertId}:`;
for (const [key, st] of stateByKey.entries()) {
if (!key.startsWith(prefix)) continue;
if (st.handled) totalHandled++;
else if (st.checked) currentlyChecking++;
}
return { totalHandled, currentlyChecking };
}
private distinctFilesWithCheckInWindow(
events: NormalizedActivity[],
expertId: string,
from: Date,
to: Date,
): number {
const files = new Set<string>();
for (const e of events) {
if (e.expertId !== expertId) continue;
if (e.eventType !== ExpertFileActivityType.CHECKED) continue;
if (e.occurredAt < from || e.occurredAt > to) continue;
files.add(e.fileId);
}
return files.size;
}
private async loadTenantExpertsAndActivities(clientKey: string): Promise<{
events: NormalizedActivity[];
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>;
}> {
const tenantOid = new Types.ObjectId(clientKey);
const tenantStr = String(tenantOid);
/**
* `clientKey` is sometimes stored as ObjectId, sometimes as string — match both.
* Activity is loaded for the whole tenant so claim (V2) damage experts still appear
* even if a roster row was missed by shape mismatches; we then merge `_id` lookups.
*/
const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([
this.expertDbService.findAll({
$or: [{ clientKey: tenantStr }, { clientKey: tenantOid }],
} as never),
this.damageExpertDbService.findAll({
$or: [{ clientKey: tenantOid }, { clientKey: tenantStr }],
} as never),
this.expertFileActivityDbService.findByTenant(tenantOid),
]);
const expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}> = [];
const seenIds = new Set<string>();
const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => {
if (seenIds.has(id)) return;
seenIds.add(id);
expertRows.push({ id, fullName, kind });
};
for (const e of blamePanelExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "expert");
}
for (const e of damageExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "damage_expert");
}
const activityExpertIds = [
...new Set(
rawEvents
.map((row) => String(row.expertId))
.filter((id) => Types.ObjectId.isValid(id)),
),
].filter((id) => !seenIds.has(id));
if (activityExpertIds.length > 0) {
const oids = activityExpertIds.map((id) => new Types.ObjectId(id));
const [extraDamages, extraExperts] = await Promise.all([
this.damageExpertDbService.findAll({ _id: { $in: oids } } as never),
this.expertDbService.findAll({ _id: { $in: oids } } as never),
]);
for (const d of extraDamages) {
const id = String((d as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() ||
(d as any).email ||
id;
pushRow(id, fullName, "damage_expert");
}
for (const x of extraExperts) {
const id = String((x as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() ||
(x as any).email ||
id;
pushRow(id, fullName, "expert");
}
}
const events: NormalizedActivity[] = rawEvents.map((row) => ({
expertId: String(row.expertId),
fileId: String(row.fileId),
eventType: row.eventType,
occurredAt:
row.occurredAt instanceof Date
? row.occurredAt
: new Date(row.occurredAt as string | Date),
}));
return { events, expertRows };
}
private buildWorkLogEntries(
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>,
events: NormalizedActivity[],
cutoff: Date,
checkedWindow: { from: Date; to: Date },
): InsurerExpertWorkLogEntryDtoRs[] {
const state = this.replayExpertFileStates(events, cutoff);
return expertRows.map((row) => {
const snap = this.snapshotCountsForExpert(state, row.id);
return new InsurerExpertWorkLogEntryDtoRs(
row.id,
row.fullName,
row.kind,
snap.totalHandled,
snap.currentlyChecking,
this.distinctFilesWithCheckInWindow(
events,
row.id,
checkedWindow.from,
checkedWindow.to,
),
);
});
}
async getInsurerExpertWorkLog(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const now = new Date();
const epoch = new Date(0);
const entries = this.buildWorkLogEntries(expertRows, events, now, {
from: epoch,
to: now,
});
return new InsurerExpertWorkLogResponseDtoRs(entries);
}
async getInsurerExpertWorkLogPerMonth(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = [];
const now = new Date();
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const experts = this.buildWorkLogEntries(
expertRows,
events,
monthEnd,
{ from: monthStart, to: monthEnd },
);
result.unshift(
new InsurerExpertWorkLogPerMonthRowDtoRs(
monthStart,
monthEnd,
label,
experts,
),
);
}
return result;
}
}

View File

@@ -42,6 +42,10 @@ export class Workflow {
@Prop({ type: Date })
lockedAt?: Date;
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
@Prop({ type: Date })
expiredAt?: Date;
@Prop({ type: ActorLockSchema })
lockedBy?: ActorLock;
}

View File

@@ -6,7 +6,6 @@ import {
Param,
UseGuards,
Get,
Query,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
@@ -18,7 +17,6 @@ import {
ApiBearerAuth,
ApiBody,
ApiParam,
ApiQuery,
ApiTags,
ApiOperation,
ApiResponse,
@@ -48,6 +46,7 @@ export class ExpertInitiatedController {
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@ApiOperation({ deprecated: true })
@Get("my-files")
@ApiOperation({
summary: "List my expert-initiated files",
@@ -61,6 +60,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("complete-blame-data/:requestId")
@ApiOperation({
summary: "Submit all blame-needed data",
@@ -86,6 +86,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("complete-claim-data/:claimRequestId")
@ApiOperation({
summary: "Submit claim-needed data",
@@ -111,6 +112,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("create")
@ApiOperation({
summary: "Create expert-initiated blame file",
@@ -138,6 +140,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("complete-form-third-party/:requestId")
@ApiOperation({
summary: "Expert completes all information for IN_PERSON THIRD_PARTY file",
@@ -167,6 +170,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("complete-form-car-body/:requestId")
@ApiOperation({
summary: "Expert completes all information for IN_PERSON CAR_BODY file",
@@ -196,6 +200,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("upload-video/:requestId")
@ApiOperation({
summary: "Expert uploads video for expert-initiated file",
@@ -252,6 +257,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("upload-voice/:requestId")
@ApiOperation({
summary: "Expert uploads voice for expert-initiated file",
@@ -309,6 +315,7 @@ export class ExpertInitiatedController {
);
}
@ApiOperation({ deprecated: true })
@Post("add-accident-fields/:requestId")
@ApiOperation({
summary: "Expert adds accident fields to expert-initiated file",

View 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();
});
});

View File

@@ -11,8 +11,6 @@ import {
Req,
Put,
UploadedFiles,
Patch,
Query,
} from "@nestjs/common";
import {
FileFieldsInterceptor,
@@ -23,8 +21,8 @@ import {
ApiBody,
ApiConsumes,
ApiParam,
ApiQuery,
ApiTags,
ApiOperation,
} from "@nestjs/swagger";
import { Request } from "express";
import { diskStorage } from "multer";
@@ -44,7 +42,6 @@ import {
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
import { InPersonVisitDto } from "src/claim-request-management/dto/in-person-visit.dto";
@Controller("blame-request-management")
@ApiTags("blame-request-management")
@@ -56,6 +53,7 @@ export class RequestManagementController {
private readonly requestManagementService: RequestManagementService,
) {}
@ApiOperation({ deprecated: true })
@Post()
@UseGuards(GlobalGuard)
async createRequest(
@@ -68,6 +66,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/initial-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: InitialFormDto })
@@ -115,6 +114,7 @@ export class RequestManagementController {
)
@UseGuards(GlobalGuard)
@ApiParam({ name: "requestId" })
@ApiOperation({ deprecated: true })
@Post("upload-video/:requestId")
@UseGuards(GlobalGuard)
async videoUploader(
@@ -129,6 +129,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/add-plate/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: AddPlateDto })
@@ -146,6 +147,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/carbody-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@@ -163,6 +165,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/carbody-secondform/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodySecondForm })
@@ -180,6 +183,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@@ -228,6 +232,7 @@ export class RequestManagementController {
}),
)
@ApiParam({ name: "requestId" })
@ApiOperation({ deprecated: true })
@Post("upload-voice/:requestId")
@UseGuards(GlobalGuard)
async voiceUploader(
@@ -242,6 +247,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("/add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@@ -259,6 +265,7 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Post("add-second-party-details/:phoneNumber/:requestId/:frontendRoutes")
@ApiParam({ name: "phoneNumber" })
@ApiParam({ name: "requestId" })
@@ -280,17 +287,20 @@ export class RequestManagementController {
);
}
@ApiOperation({ deprecated: true })
@Get("requests")
async requests(@CurrentUser() user) {
return await this.requestManagementService.myRequests(user.username);
}
@ApiOperation({ deprecated: true })
@Get("creatable-claims")
@UseGuards(GlobalGuard)
async getCreatableClaims(@CurrentUser() user) {
return await this.requestManagementService.getCreatableClaims(user);
}
@ApiOperation({ deprecated: true })
@Get("request/:requestId")
@ApiParam({ name: "requestId" })
@Roles(RoleEnum.USER)
@@ -329,6 +339,7 @@ export class RequestManagementController {
}),
}),
)
@ApiOperation({ deprecated: true })
@Put("request/reply/:requestId")
@ApiParam({ name: "requestId" })
@UseGuards(GlobalGuard)
@@ -385,6 +396,7 @@ export class RequestManagementController {
)
@UseGuards(GlobalGuard)
@ApiParam({ name: "requestId" })
@ApiOperation({ deprecated: true })
@Put("request/resend/:requestId")
userResendReply(
@Body("description") description: string,

View File

@@ -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();
});
});

View File

@@ -28,7 +28,10 @@ import { UserSignatureResponseDto } from "./dto/user-signature.dto";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import {
buildResendItemsWithUi,
cloneResendUploadedDocuments,
isResendPartyItemSatisfied,
normalizeResendRequestedItemKey,
normalizeResendRequestedItemsList,
} from "src/Types&Enums/blame-request-management/resend-item-ui";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
@@ -71,6 +74,11 @@ import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-
import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
import { UploadContext } from "./entities/schema/blame-voice.schema";
import { HashService } from "src/utils/hash/hash.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
@@ -309,6 +317,7 @@ export class RequestManagementService {
private readonly userSign: UserSignDbService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertDbService: ExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
private readonly autoCloseRequestService: AutoCloseRequestService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
@@ -2370,9 +2379,6 @@ export class RequestManagementService {
}
if (p.firstPartyDetails.firstPartyPhoneNumber == phoneNumber) {
console.log(
new Date(p.createdAt).toLocaleTimeString("fa-IR").split(","),
);
res.listOfFirstParty.push({
id: p.id,
blameStatus: p.blameStatus,
@@ -2746,76 +2752,28 @@ export class RequestManagementService {
return null;
}
private async updateDamageExpertStats(
expertId: string,
requestId: string,
type: "checked" | "handled",
) {
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
console.warn("Invalid expertId, requestId, or type");
private async recordBlameExpertActivity(args: {
expertId: string;
tenantId: string;
requestId: string;
eventType: ExpertFileActivityType;
idempotencyKey?: string;
}): Promise<void> {
if (
!Types.ObjectId.isValid(args.expertId) ||
!Types.ObjectId.isValid(args.tenantId) ||
!Types.ObjectId.isValid(args.requestId)
) {
return;
}
// Validate that both expertId and requestId are valid ObjectId strings
if (!Types.ObjectId.isValid(expertId)) {
console.warn("Invalid ObjectId format for expertId:", expertId);
return;
}
if (!Types.ObjectId.isValid(requestId)) {
console.warn("Invalid ObjectId format for requestId:", requestId);
return;
}
const expert = await this.expertDbService.findOne({
_id: new Types.ObjectId(expertId),
await this.expertFileActivityDbService.recordEvent({
expertId: args.expertId,
tenantId: args.tenantId,
fileId: args.requestId,
fileType: ExpertFileKind.BLAME,
eventType: args.eventType,
idempotencyKey: args.idempotencyKey,
});
if (!expert) {
console.warn("Expert not found:", expertId);
return;
}
const requestIdStr = new Types.ObjectId(requestId).toString();
const countedRequestIds =
expert.countedRequests?.map((id) => id.toString()) || [];
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
console.log(
`Request ${requestIdStr} already checked for expert ${expertId}`,
);
return;
}
const update: any = { $inc: {}, $push: {} };
if (type === "checked") {
update.$inc["requestStats.totalChecked"] = 1;
update.$push["countedRequests"] = requestIdStr;
} else if (type === "handled") {
update.$inc["requestStats.totalHandled"] = 1;
if (countedRequestIds.includes(requestIdStr)) {
update.$inc["requestStats.totalChecked"] = -1;
}
if (!countedRequestIds.includes(requestIdStr)) {
update.$push["countedRequests"] = requestIdStr;
} else {
delete update.$push;
}
}
const updateResult = await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(expertId) },
update,
);
if (!updateResult) {
console.warn("Failed to update expert stats for:", expertId);
} else {
console.log(`Expert stats updated (${type}) for expert:`, expertId);
}
}
private async handleDisagreement(
@@ -3006,11 +2964,17 @@ export class RequestManagementService {
request._id.toString() :
String(request._id);
await this.updateDamageExpertStats(
actorIdStr,
requestIdStr,
"handled",
);
const tenantIdStr =
request.firstPartyDetails?.firstPartyClient?.clientId?.toString?.() ||
request.secondPartyDetails?.secondPartyClient?.clientId?.toString?.() ||
"";
await this.recordBlameExpertActivity({
expertId: actorIdStr,
tenantId: tenantIdStr,
requestId: requestIdStr,
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `blame:${requestIdStr}:handled:${actorIdStr}`,
});
}
await this.requestManagementDbService.findByIdAndUpdate(request._id, {
$set: { isHandledStatsUpdated: true },
@@ -6079,14 +6043,12 @@ export class RequestManagementService {
);
}
const items = (userResendRequest.requestedItems || []) as string[];
const items = normalizeResendRequestedItemsList(
userResendRequest.requestedItems as string[],
);
const row = userResendRequest as any;
const merged = {
uploadedDocuments: {
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
? { ...row.uploadedDocuments }
: {}),
},
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
resendVoiceId: row.resendVoiceId,
resendVideoId: row.resendVideoId,
userTextDescription: row.userTextDescription,
@@ -6095,7 +6057,7 @@ export class RequestManagementService {
return {
requestId: String(request._id),
requestedItems: userResendRequest.requestedItems,
requestedItems: items,
description: userResendRequest.description || "",
completed: userResendRequest.completed || false,
requestedItemsUi: buildResendItemsWithUi(items),
@@ -6161,22 +6123,26 @@ export class RequestManagementService {
}
const userResendRequest = resendParties[resendIndex];
const requestedItems = userResendRequest.requestedItems || [];
const requestedItems = normalizeResendRequestedItemsList(
userResendRequest.requestedItems as string[],
);
if (requestedItems.length === 0) {
throw new BadRequestException(
"No valid requested items are configured for your party resend. Please contact support.",
);
}
const row = userResendRequest as any;
const mergedRow = {
uploadedDocuments: {
...(typeof row.uploadedDocuments === "object" && row.uploadedDocuments
? { ...row.uploadedDocuments }
: {}),
},
uploadedDocuments: cloneResendUploadedDocuments(row.uploadedDocuments),
resendVoiceId: row.resendVoiceId,
resendVideoId: row.resendVideoId,
userTextDescription: row.userTextDescription,
};
const hasAnyFile = Object.keys(files || {}).some((fieldName) => {
const fileArray = files[fieldName as keyof typeof files];
const safeFiles = files || {};
const hasAnyFile = Object.keys(safeFiles).some((fieldName) => {
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
return fileArray && fileArray.length > 0 && fileArray[0];
});
const descTrim =
@@ -6186,7 +6152,7 @@ export class RequestManagementService {
if (!hasAnyFile && !sendingDescription) {
throw new BadRequestException(
"Provide at least one requested file or a text description when description was requested.",
"Provide at least one file for a requested item and/or the text description when the expert asked for a description.",
);
}
@@ -6201,19 +6167,20 @@ export class RequestManagementService {
uploadedItems.push(ResendItemType.DESCRIPTION);
}
for (const fieldName in files) {
const fileArray = files[fieldName as keyof typeof files];
for (const fieldName of Object.keys(safeFiles)) {
const fileArray = safeFiles[fieldName as keyof typeof safeFiles];
if (!fileArray || fileArray.length === 0) continue;
const file = fileArray[0];
if (!requestedItems.includes(fieldName)) {
const canonKey = normalizeResendRequestedItemKey(fieldName);
if (!canonKey || !requestedItems.includes(canonKey)) {
throw new BadRequestException(
`${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`,
`${fieldName} was not requested or is not a valid resend field. Requested items: ${requestedItems.join(", ")}`,
);
}
if (fieldName === "voice") {
if (canonKey === ResendItemType.VOICE) {
const voiceDoc = await this.blameVoiceDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
@@ -6227,8 +6194,8 @@ export class RequestManagementService {
`expert.resend.parties.${resendIndex}.resendVoiceId`
] = (voiceDoc as any)._id;
mergedRow.resendVoiceId = (voiceDoc as any)._id;
uploadedItems.push(fieldName);
} else if (fieldName === "video") {
uploadedItems.push(canonKey);
} else if (canonKey === ResendItemType.VIDEO) {
const videoDoc = await this.blameVideoDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
@@ -6240,9 +6207,9 @@ export class RequestManagementService {
`expert.resend.parties.${resendIndex}.resendVideoId`
] = (videoDoc as any)._id;
mergedRow.resendVideoId = (videoDoc as any)._id;
uploadedItems.push(fieldName);
uploadedItems.push(canonKey);
} else {
const docType = fieldName as BlameDocumentType;
const docType = canonKey as BlameDocumentType;
const doc = await this.blameDocumentDbService.create({
path: file.path,
requestId: new Types.ObjectId(requestId),
@@ -6250,16 +6217,16 @@ export class RequestManagementService {
documentType: docType,
});
updatePayload.$set[
`expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}`
`expert.resend.parties.${resendIndex}.uploadedDocuments.${canonKey}`
] = (doc as any)._id;
mergedRow.uploadedDocuments[fieldName] = (doc as any)._id;
uploadedItems.push(fieldName);
mergedRow.uploadedDocuments[canonKey] = (doc as any)._id;
uploadedItems.push(canonKey);
}
}
const allItemsCompleted = (requestedItems as string[]).every((item) =>
isResendPartyItemSatisfied(item, mergedRow),
);
const allItemsCompleted =
requestedItems.length > 0 &&
requestedItems.every((item) => isResendPartyItemSatisfied(item, mergedRow));
if (allItemsCompleted) {
updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true;
@@ -6270,7 +6237,7 @@ export class RequestManagementService {
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
const updatedRequest = await this.blameRequestDbService.findById(requestId);
const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every(
const allPartiesCompleted = updatedRequest?.expert?.resend?.parties?.every(
(p) => p.completed,
);
@@ -6446,6 +6413,41 @@ export class RequestManagementService {
"workflow.completedSteps": "WAITING_FOR_SIGNATURES",
},
});
if (
updatedRequest.type === BlameRequestType.THIRD_PARTY ||
updatedRequest.type === BlameRequestType.CAR_BODY
) {
let targetPhone: string | undefined;
if (updatedRequest.type === BlameRequestType.THIRD_PARTY) {
const guiltyPartyId = updatedRequest.expert?.decision?.guiltyPartyId
? String(updatedRequest.expert.decision.guiltyPartyId)
: null;
if (guiltyPartyId) {
const damagedParty = updatedRequest.parties?.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
);
targetPhone = damagedParty?.person?.phoneNumber?.trim();
}
} else {
// CAR_BODY has a single owner/damaged party: notify that user too.
const ownerParty = updatedRequest.parties?.[partyIndex] ?? updatedRequest.parties?.[0];
targetPhone = ownerParty?.person?.phoneNumber?.trim();
}
if (targetPhone) {
const publicId = String(updatedRequest.publicId);
const link = this.smsOrchestrationService.buildClaimLink(requestId);
await this.smsOrchestrationService.sendThirdPartyDamagedPartyClaimLinkNotice(
{
receptor: targetPhone,
publicId,
link,
},
);
}
}
} else {
finalStatus = CaseStatus.STOPPED;
message =

View File

@@ -1,4 +1,5 @@
import { extname } from "node:path";
import { mkdirSync } from "node:fs";
import {
Body,
Controller,
@@ -398,6 +399,11 @@ export class RequestManagementV2Controller {
description:
"Text description when expert requested ResendItemType.description",
},
textDescription: {
type: "string",
description:
"Alias for `description` (same semantics); either field may be used.",
},
},
},
})
@@ -414,7 +420,9 @@ export class RequestManagementV2Controller {
{
storage: diskStorage({
destination: (req, file, cb) => {
cb(null, "./uploads");
const uploadDir = "./files/blame-resend";
mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix =
@@ -439,12 +447,18 @@ export class RequestManagementV2Controller {
video?: Express.Multer.File[];
},
@Body("description") description?: string,
@Body("textDescription") textDescription?: string,
) {
const descCandidates = [description, textDescription]
.map((x) => (x != null ? String(x).trim() : ""))
.filter((s) => s.length > 0);
const mergedDescription =
descCandidates.length > 0 ? descCandidates[0] : undefined;
return this.requestManagementService.uploadResendDocumentsV2(
requestId,
user.sub,
files,
description,
mergedDescription,
);
}
}

View File

@@ -22,6 +22,7 @@ export interface VerifyLookUpMessage {
token: string;
token2?: string;
token3?: string;
token10?: string;
receptor: string;
}

View File

@@ -10,6 +10,7 @@ type TemplateArgs = {
token: string;
token2?: string;
token3?: string;
token10?: string;
};
@Injectable()
@@ -40,6 +41,15 @@ export class SmsOrchestrationService implements OnModuleInit {
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
}
buildBlamePartyLink(requestId: string, partyRole: "FIRST" | "SECOND"): string {
const route = partyRole === "SECOND" ? "user2" : "user";
return `${process.env.URL}/${route}?token=${requestId}`;
}
buildClaimLink(claimRequestId: string): string {
return `${process.env.URL}/caseClaim?token=${claimRequestId}`;
}
async sendInviteLink(phoneNumber: string, link: string): Promise<boolean> {
return this.sendTemplate({
template: "yara724-invite-link",
@@ -107,20 +117,66 @@ export class SmsOrchestrationService implements OnModuleInit {
});
}
/** THIRD_PARTY only: notify the damaged (non-guilty) party that blame is completed and they can open the claim flow. */
async sendThirdPartyDamagedPartyClaimLinkNotice(params: {
receptor: string;
publicId: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-claim-link",
receptor: params.receptor,
token: params.publicId,
token2: params.link,
});
}
async sendThirdPartyExpertStartedReviewNotice(
params: {
receptor: string;
fileKind: "blame" | "claim";
publicId: string;
expertLastName: string;
},
): Promise<boolean> {
return this.sendTemplate({
template:
process.env.SMS_TEMPLATE_THIRD_PARTY_EXPERT_LOCK ||
"yara-blame-expert-lock",
template: "yara-expert-lock",
receptor: params.receptor,
token: params.publicId,
token2: params.expertLastName || "کارشناس",
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
token2: params.publicId,
token3: params.expertLastName || "کارشناس",
});
}
async sendResendDocumentsNotice(params: {
receptor: string;
fileKind: "blame" | "claim";
publicId: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-resend-documents",
receptor: params.receptor,
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
token2: params.publicId,
token3: params.link,
});
}
async sendSignatureReviewNotice(params: {
receptor: string;
fileKind: "blame" | "claim";
publicId: string;
expertLastName: string;
link: string;
}): Promise<boolean> {
return this.sendTemplate({
template: "yara-signature",
receptor: params.receptor,
token: params.fileKind === "blame" ? "تصادف" : "خسارت",
token2: params.publicId,
token3: params.expertLastName || "کارشناس",
token10: params.link,
});
}

View 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),
};

View File

@@ -46,64 +46,6 @@ export class DamageExpertDbService {
.exec();
}
async updateStats(
expertId: string,
type: "checked" | "handled",
requestId: string,
) {
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
console.warn("Invalid expertId, requestId, or type");
return;
}
const expert = await this.damageExpertModel.findById(expertId);
if (!expert) {
console.warn("Expert not found:", expertId);
return;
}
const requestIdStr = new Types.ObjectId(requestId).toString();
const countedRequests =
expert.countedRequests?.map((r) => r.toString()) || [];
if (type === "checked" && countedRequests.includes(requestIdStr)) {
console.log(
`Request ${requestIdStr} already checked for expert ${expertId}`,
);
return;
}
const update: any = { $inc: {}, $push: {} };
if (type === "checked") {
update.$inc["requestStats.totalChecked"] = 1;
update.$push["countedRequests"] = requestIdStr;
} else if (type === "handled") {
update.$inc["requestStats.totalHandled"] = 1;
if (countedRequests.includes(requestIdStr)) {
update.$inc["requestStats.totalChecked"] = -1;
}
if (!countedRequests.includes(requestIdStr)) {
update.$push["countedRequests"] = requestIdStr;
} else {
delete update.$push;
}
}
const result = await this.damageExpertModel.findByIdAndUpdate(
expertId,
update,
);
if (!result) {
console.warn("Failed to update stats for expert:", expertId);
} else {
console.log(`Stats updated (${type}) for expert:`, expertId);
}
}
async updateOne(filter: any, update: any) {
return this.damageExpertModel.updateOne(filter, update);
}

View File

@@ -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();
}
}

View File

@@ -33,50 +33,6 @@ export class ExpertDbService {
return await this.expertModel.find(filter).lean();
}
async updateStats(
expertId: string,
type: "checked" | "handled",
requestId: string,
) {
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
console.warn("Invalid expertId, requestId, or type");
return;
}
const expert = await this.expertModel.findById(expertId);
if (!expert) {
console.warn("Expert not found:", expertId);
return;
}
const requestIdStr = new Types.ObjectId(requestId).toString();
const counted = (expert.countedRequests || []).map((r) => r.toString());
if (counted.includes(requestIdStr)) {
console.log("Request already counted for expert:", requestIdStr);
return;
}
const updateField =
type === "handled"
? {
"requestStats.totalHandled": 1,
"requestStats.totalChecked": -1,
}
: { "requestStats.totalChecked": 1 };
const result = await this.expertModel.findByIdAndUpdate(expertId, {
$inc: updateField,
$push: { countedRequests: requestIdStr },
});
if (!result) {
console.warn("Failed to update expert stats for:", expertId);
} else {
console.log("Updated stats for expert:", expertId);
}
}
async updateOne(filter: any, update: any) {
return this.expertModel.updateOne(filter, update);
}

View File

@@ -8,16 +8,6 @@ import {
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
@Schema({ _id: false })
export class RequestStats {
@Prop({ default: 0 })
totalHandled: number;
@Prop({ default: 0 })
totalChecked: number;
}
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
@Schema({ _id: false })
export class PersonalInfo {
@@ -133,6 +123,9 @@ export class DamageExpertModel {
@Prop({ type: Types.ObjectId })
clientKey?: Types.ObjectId;
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
@Prop({ type: PersonalInfoSchema })
personalInfo?: PersonalInfo;
@@ -166,19 +159,6 @@ export class DamageExpertModel {
@Prop({ type: "string" })
city: string;
@Prop({ type: RequestStatsSchema, default: () => ({}) })
requestStats: RequestStats;
@Prop({
type: [{ type: String }],
default: [],
validate: {
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
message: "All countedRequests must be strings",
},
})
countedRequests?: string[];
createdAt: Date;
}
@@ -190,11 +170,4 @@ DamageExpertDbSchema.pre("save", function (next) {
this.username = this.email;
}
next();
});
DamageExpertDbSchema.pre("save", function (next) {
if (!this.requestStats) {
this.requestStats = { totalChecked: 0, totalHandled: 0 };
}
next();
});
});

View 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 } } },
);

View File

@@ -1,19 +1,9 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { Degrees } from "src/Types&Enums/degrees.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserType } from "src/Types&Enums/userType.enum";
@Schema({ _id: false })
export class RequestStats {
@Prop({ default: 0 })
totalHandled: number;
@Prop({ default: 0 })
totalChecked: number;
}
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
@Schema({ collection: "expert", versionKey: false, timestamps: true })
export class ExpertModel {
@Prop({})
@@ -76,30 +66,14 @@ export class ExpertModel {
@Prop({})
clientKey?: string;
@Prop({ type: Types.ObjectId, index: true })
branchId?: Types.ObjectId;
@Prop({ type: "string" })
otp: string;
@Prop({ type: RequestStatsSchema, default: () => ({}) })
requestStats: RequestStats;
@Prop({
type: [{ type: String }],
default: [],
validate: {
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
message: "All countedRequests must be strings",
},
})
countedRequests?: string[];
createdAt: Date;
}
export const ExpertDbSchema = SchemaFactory.createForClass(ExpertModel);
ExpertDbSchema.pre("save", function (next) {
if (!this.requestStats) {
this.requestStats = { totalChecked: 0, totalHandled: 0 };
}
next();
});

View File

@@ -2,17 +2,6 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({ _id: false })
export class RequestStats {
@Prop({ default: 0 })
totalHandled: number;
@Prop({ default: 0 })
totalChecked: number;
}
const RequestStatsSchema = SchemaFactory.createForClass(RequestStats);
@Schema({
collection: "field-expert",
versionKey: false,
@@ -48,31 +37,8 @@ export class FieldExpertModel {
@Prop({ type: "string", default: "" })
otp: string;
@Prop({ type: RequestStatsSchema, default: () => ({}) })
requestStats: RequestStats;
@Prop({
type: [{ type: String }],
default: [],
validate: {
validator: (arr: any[]) => arr.every((val) => typeof val === "string"),
message: "All countedRequests must be strings",
},
})
countedRequests?: string[];
createdAt: Date;
}
export const FieldExpertDbSchema =
SchemaFactory.createForClass(FieldExpertModel);
FieldExpertDbSchema.pre("save", function (next) {
if (!this.username) {
this.username = this.email;
}
if (!this.requestStats) {
this.requestStats = { totalChecked: 0, totalHandled: 0 };
}
next();
});
SchemaFactory.createForClass(FieldExpertModel);

View File

@@ -9,10 +9,15 @@ import { FieldExpertDbService } from "./entities/db-service/field-expert.db.serv
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
import { UserDbService } from "./entities/db-service/user.db.service";
import { ExpertFileActivityDbService } from "./entities/db-service/expert-file-activity.db.service";
import {
DamageExpertDbSchema,
DamageExpertModel,
} from "./entities/schema/damage-expert.schema";
import {
ExpertFileActivity,
ExpertFileActivitySchema,
} from "./entities/schema/expert-file-activity.schema";
import { ExpertDbSchema } from "./entities/schema/expert.schema";
import {
FieldExpertDbSchema,
@@ -37,6 +42,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
{ name: ExpertFileActivity.name, schema: ExpertFileActivitySchema },
]),
OtpModule,
HashModule,
@@ -49,6 +55,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
FieldExpertDbService,
InsurerExpertDbService,
RegistrarDbService,
ExpertFileActivityDbService,
],
exports: [
UserDbService,
@@ -57,6 +64,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
FieldExpertDbService,
InsurerExpertDbService,
RegistrarDbService,
ExpertFileActivityDbService,
],
})
export class UsersModule {}

View File

@@ -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 };