1
0
forked from Yara724/api

Compare commits

..

3 Commits

Author SHA1 Message Date
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
5 changed files with 225 additions and 54 deletions

View File

@@ -2805,6 +2805,27 @@ export class ClaimRequestManagementService {
}, 0); }, 0);
} }
/** Branch ids referenced on expert-priced parts (`daghi` object with `branchId`). */
private collectBranchIdsFromClaimExpertReply(reply: {
parts?: unknown[];
}): Set<string> {
const ids = new Set<string>();
const parts = reply?.parts;
if (!Array.isArray(parts)) {
return ids;
}
for (const p of parts) {
const d = (p as { daghi?: unknown })?.daghi;
if (d && typeof d === "object" && d !== null && "branchId" in d) {
const bid = (d as { branchId?: unknown }).branchId;
if (bid != null && Types.ObjectId.isValid(String(bid))) {
ids.add(String(bid));
}
}
}
return ids;
}
/** /**
* Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format * Fanavaran Submit - Map data from claim-request-management and request-management to third-party-car-financial-claims format
*/ */
@@ -5139,6 +5160,7 @@ export class ClaimRequestManagementService {
async submitOwnerInsurerApprovalSignV2( async submitOwnerInsurerApprovalSignV2(
claimRequestId: string, claimRequestId: string,
agree: boolean, agree: boolean,
branchId: string,
signFile: Express.Multer.File, signFile: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string; fullName?: string }, actor?: { sub: string; role?: string; fullName?: string },
@@ -5200,6 +5222,34 @@ export class ClaimRequestManagementService {
throw new BadRequestException("No expert reply is available to sign off on yet."); 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({ const signDoc = await this.claimSignDbService.create({
fileName: signFile.filename, fileName: signFile.filename,
userId: effectiveUserId, userId: effectiveUserId,
@@ -5210,6 +5260,7 @@ export class ClaimRequestManagementService {
const signedAt = new Date(); const signedAt = new Date();
const approvalPayload = { const approvalPayload = {
agree, agree,
branchId: new Types.ObjectId(rawBranchId),
signDetailId: signId, signDetailId: signId,
signedAt, signedAt,
}; };
@@ -5233,7 +5284,7 @@ export class ClaimRequestManagementService {
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING", type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
actor: historyActor, actor: historyActor,
timestamp: signedAt, timestamp: signedAt,
metadata: {}, metadata: { branchId: rawBranchId },
}, },
}, },
}); });
@@ -5262,7 +5313,7 @@ export class ClaimRequestManagementService {
type: "OWNER_SIGNED_INSURER_APPROVAL", type: "OWNER_SIGNED_INSURER_APPROVAL",
actor: historyActor, actor: historyActor,
timestamp: signedAt, timestamp: signedAt,
metadata: {}, metadata: { branchId: rawBranchId },
}, },
}, },
}); });

View File

@@ -85,7 +85,7 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get Claim Details (V2)", summary: "Get Claim Details (V2)",
description: description:
"Get claim details for current actor. USER receives only minimal own-needed payload (no extra owner/security fields). FIELD_EXPERT keeps full view for expert workflows.", "Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.",
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -245,21 +245,27 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Sign final claim pricing (owner)", summary: "Sign final claim pricing (owner)",
description: description:
"Multipart: `sign` (signature image) and `agree` (boolean). Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " + "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. " + "`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.", "If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
}) })
@ApiBody({ @ApiBody({
description: "Signature file and agreement", description: "Signature file, agreement, and branch",
schema: { schema: {
type: "object", type: "object",
required: ["sign", "agree"], required: ["sign", "agree", "branchId"],
properties: { properties: {
sign: { type: "string", format: "binary", description: "Signature image" }, sign: { type: "string", format: "binary", description: "Signature image" },
agree: { agree: {
type: "boolean", type: "boolean",
description: "true to accept expert pricing and complete the claim", 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",
},
}, },
}, },
}) })
@@ -285,6 +291,7 @@ export class ClaimRequestManagementV2Controller {
async submitOwnerInsurerApprovalSignV2( async submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean, @Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File, @UploadedFile() sign: Express.Multer.File,
) { ) {
@@ -297,6 +304,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2( return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId, claimRequestId,
agreed, agreed,
typeof branchId === "string" ? branchId : "",
sign, sign,
user.sub, user.sub,
user, user,

View File

@@ -82,6 +82,10 @@ export class ClaimOwnerInsurerApproval {
@Prop({ type: Boolean, required: true }) @Prop({ type: Boolean, required: true })
agree: boolean; 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 }) @Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId; signDetailId?: Types.ObjectId;

View File

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

View File

@@ -245,6 +245,24 @@ export class ExpertClaimService {
return String(actor.clientKey ?? ""); return String(actor.clientKey ?? "");
} }
/**
* `preLockQueueSnapshot` must only store {@link ClaimWorkflowStep} values.
* Legacy / mistaken data sometimes put {@link ClaimCaseStatus} (e.g. WAITING_FOR_DAMAGE_EXPERT)
* in `workflow.currentStep`, which would fail Mongoose enum validation when snapshotted.
*/
private normalizeClaimWorkflowStepForSnapshot(
value: unknown,
fallback: ClaimWorkflowStep,
): ClaimWorkflowStep {
if (
typeof value === "string" &&
(Object.values(ClaimWorkflowStep) as string[]).includes(value)
) {
return value as ClaimWorkflowStep;
}
return fallback;
}
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */ /** Owner mobile: linked blame party phone when available, else `users.mobile`. */
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> { private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined; if (!claim?.owner?.userId) return undefined;
@@ -2226,20 +2244,30 @@ export class ExpertClaimService {
}, },
'workflow.preLockQueueSnapshot': { 'workflow.preLockQueueSnapshot': {
claimStatus: claim.claimStatus, claimStatus: claim.claimStatus,
currentStep: currentStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, claim.workflow?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
),
...(claim.workflow?.nextStep != null ...(claim.workflow?.nextStep != null
? { nextStep: claim.workflow.nextStep } ? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}), : {}),
}, },
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: { $push: {
history: { history: {
event: 'CLAIM_LOCKED', type: "CLAIM_LOCKED",
performedBy: actor.sub, actor: {
performedByName: actor.fullName, actorId: new Types.ObjectId(actor.sub),
performedAt: new Date(), actorName: actor.fullName,
note: `Claim locked by damage expert ${actor.fullName}`, actorType: "damage_expert",
},
timestamp: new Date(),
metadata: { note: `Claim locked by damage expert ${actor.fullName}` },
}, },
}, },
}); });
@@ -2657,11 +2685,16 @@ export class ExpertClaimService {
}), }),
$push: { $push: {
history: { history: {
event: 'IN_PERSON_VISIT_REQUESTED', type: "IN_PERSON_VISIT_REQUESTED",
performedBy: actor.sub, actor: {
performedByName: actor.fullName, actorId: new Types.ObjectId(actor.sub),
performedAt: new Date(), actorName: actor.fullName,
note: note || 'Expert requested in-person visit', actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
note: note || "Expert requested in-person visit",
},
}, },
}, },
}); });
@@ -2678,7 +2711,7 @@ export class ExpertClaimService {
claimRequestId, claimRequestId,
status: claim.status, status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION, claimStatus: ClaimStatus.NEEDS_REVISION,
message: 'In-person visit requested. User will be notified.', message: "In-person visit requested. User will be notified.",
}; };
} }
@@ -2723,6 +2756,11 @@ export class ExpertClaimService {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true }, 'workflow.locked': { $ne: true },
}, },
// Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims // This expert's own locked/in-progress claims
{ {
'workflow.locked': true, 'workflow.locked': true,
@@ -2919,6 +2957,34 @@ export class ExpertClaimService {
return docRec; return docRec;
} }
/** Strip bulky insurer-inquiry payloads (`raw` / `mapped`) from vehicle.inquiry. */
private sanitizeVehicleInquiryForApi(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 as Record<string, unknown>;
return {
...vehicle,
inquiry: safeInquiry,
};
}
/** Linked blame snapshot for damage-expert detail: keep API response lean. */
private sanitizeBlameCaseForClaimDetailApi(
blame: Record<string, unknown>,
): Record<string, unknown> {
const out = { ...blame };
const parties = out.parties;
if (Array.isArray(parties)) {
out.parties = parties.map((p: any) =>
p && typeof p === "object"
? { ...p, vehicle: this.sanitizeVehicleInquiryForApi(p.vehicle) }
: p,
);
}
return out;
}
/** JSON-safe `expert.decision` for API responses (ObjectIds → string). */ /** JSON-safe `expert.decision` for API responses (ObjectIds → string). */
private serializeBlameExpertDecisionForClaimDetail( private serializeBlameExpertDecisionForClaimDetail(
decision: unknown, decision: unknown,
@@ -3025,8 +3091,9 @@ export class ExpertClaimService {
/** /**
* Persist unlock when claim V2 workflow lock TTL has passed. * Persist unlock when claim V2 workflow lock TTL has passed.
* If the expert never submitted a damage reply, restores queue fields from * When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from
* `workflow.preLockQueueSnapshot` (or defaults) so the case shows as WAITING_FOR_DAMAGE_EXPERT again. * `workflow.preLockQueueSnapshot` (or defaults) and sets status back to WAITING_FOR_DAMAGE_EXPERT
* so the claim reappears in the open expert queue (same intent as blame list expiry + in-memory unlock).
*/ */
private async expireClaimWorkflowLockV2IfStale( private async expireClaimWorkflowLockV2IfStale(
claimRequestId: string, claimRequestId: string,
@@ -3047,7 +3114,6 @@ export class ExpertClaimService {
) { ) {
return false; return false;
} }
if (!expiredAt && !lockedAt) return false;
const lockedById = claim.workflow.lockedBy?.actorId; const lockedById = claim.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = { const filter: Record<string, unknown> = {
@@ -3059,12 +3125,10 @@ export class ExpertClaimService {
} else if (lockedAt) { } else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt; filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) { } else if (lockedById) {
filter["workflow.lockedBy.actorId"] = lockedById; filter["workflow.lockedBy.actorId"] = new Types.ObjectId(String(lockedById));
} }
const needsQueueRestore = const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
claim.status === ClaimCaseStatus.EXPERT_REVIEWING &&
!this.claimHasDamageExpertReply(claim);
const snap = claim.workflow?.preLockQueueSnapshot as const snap = claim.workflow?.preLockQueueSnapshot as
| { | {
@@ -3075,11 +3139,16 @@ export class ExpertClaimService {
| undefined; | undefined;
const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING; const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING;
const restoreCurrent = const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot(
snap?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE; snap?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
);
const restoreNext = const restoreNext =
snap?.nextStep != null snap?.nextStep != null
? snap.nextStep ? this.normalizeClaimWorkflowStepForSnapshot(
snap.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
)
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; : ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const lockFieldsUnset = { const lockFieldsUnset = {
@@ -3136,6 +3205,7 @@ export class ExpertClaimService {
actor: any, actor: any,
): Promise<ClaimDetailV2ResponseDto> { ): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub; const actorId = actor.sub;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId); await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
@@ -3319,6 +3389,21 @@ export class ExpertClaimService {
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
: claim.status; : claim.status;
const objection = (claim as any).evaluation?.objection
? {
objectionParts:
(claim as any).evaluation.objection.objectionParts ?? [],
newParts: (claim as any).evaluation.objection.newParts ?? [],
submittedAt: (claim as any).evaluation.objection.submittedAt,
}
: undefined;
const blameCaseForApi = blameCase
? this.sanitizeBlameCaseForClaimDetailApi(
blameCase as Record<string, unknown>,
)
: undefined;
return { return {
claimRequestId: claim._id.toString(), claimRequestId: claim._id.toString(),
publicId: claim.publicId, publicId: claim.publicId,
@@ -3341,7 +3426,9 @@ export class ExpertClaimService {
fullName: claim.owner.fullName, fullName: claim.owner.fullName,
} }
: undefined, : undefined,
vehicle: vehiclePayload, vehicle: vehiclePayload
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext, ...blameFileContext,
blameRequestId: claim.blameRequestId?.toString(), blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo, blameRequestNo: claim.blameRequestNo,
@@ -3361,8 +3448,9 @@ export class ExpertClaimService {
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal, damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
} }
: undefined, : undefined,
objection,
videoCapture, videoCapture,
blameCase: blameCase ?? undefined, blameCase: blameCaseForApi,
blameExpertDecision, blameExpertDecision,
createdAt: (claim as any).createdAt, createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt, updatedAt: (claim as any).updatedAt,
@@ -3405,31 +3493,41 @@ export class ExpertClaimService {
: []; : [];
const selectedParts = body.selectedParts as string[]; const selectedParts = body.selectedParts as string[];
if (!(claim as any).damage) (claim as any).damage = {}; const $set: Record<string, unknown> = {
(claim as any).damage.selectedParts = selectedParts; "damage.selectedParts": selectedParts,
};
// Keep damaged-parts captures consistent with updated selection.
const damagedPartsMedia = (claim as any).media?.damagedParts; const damagedPartsMedia = (claim as any).media?.damagedParts;
if (damagedPartsMedia) { if (damagedPartsMedia) {
const selectedSet = new Set(selectedParts); const selectedSet = new Set(selectedParts);
if (damagedPartsMedia instanceof Map) { if (damagedPartsMedia instanceof Map) {
for (const key of Array.from(damagedPartsMedia.keys())) { const m = new Map(damagedPartsMedia as Map<string, unknown>);
if (!selectedSet.has(key)) damagedPartsMedia.delete(key); for (const key of Array.from(m.keys())) {
if (!selectedSet.has(key)) m.delete(key);
} }
$set["media.damagedParts"] = Object.fromEntries(m.entries());
} else { } else {
for (const key of Object.keys(damagedPartsMedia)) { const o = {
if (!selectedSet.has(key)) delete damagedPartsMedia[key]; ...(damagedPartsMedia as Record<string, unknown>),
};
for (const key of Object.keys(o)) {
if (!selectedSet.has(key)) delete o[key];
} }
$set["media.damagedParts"] = o;
} }
} }
if (!Array.isArray((claim as any).history)) (claim as any).history = []; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
(claim as any).history.push({ $set,
event: "EXPERT_DAMAGED_PARTS_UPDATED", $push: {
performedBy: actor.sub, history: {
performedByName: actor.fullName, type: "EXPERT_DAMAGED_PARTS_UPDATED",
performedAt: new Date(), actor: {
note: "Damage expert edited selected damaged parts", actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: { metadata: {
previousSelectedParts: previous, previousSelectedParts: previous,
selectedParts, selectedParts,
@@ -3437,10 +3535,10 @@ export class ExpertClaimService {
expertProfileSnapshot: damagedPartsEditSnapshot, expertProfileSnapshot: damagedPartsEditSnapshot,
}), }),
}, },
},
},
}); });
await (claim as any).save();
return { return {
claimRequestId: claim._id.toString(), claimRequestId: claim._id.toString(),
selectedParts, selectedParts,