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);
}
/** 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
*/
@@ -5139,6 +5160,7 @@ export class ClaimRequestManagementService {
async submitOwnerInsurerApprovalSignV2(
claimRequestId: string,
agree: boolean,
branchId: string,
signFile: Express.Multer.File,
currentUserId: 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.");
}
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,
@@ -5210,6 +5260,7 @@ export class ClaimRequestManagementService {
const signedAt = new Date();
const approvalPayload = {
agree,
branchId: new Types.ObjectId(rawBranchId),
signDetailId: signId,
signedAt,
};
@@ -5233,7 +5284,7 @@ export class ClaimRequestManagementService {
type: "OWNER_REJECTED_INSURER_APPROVAL_PRICING",
actor: historyActor,
timestamp: signedAt,
metadata: {},
metadata: { branchId: rawBranchId },
},
},
});
@@ -5262,7 +5313,7 @@ export class ClaimRequestManagementService {
type: "OWNER_SIGNED_INSURER_APPROVAL",
actor: historyActor,
timestamp: signedAt,
metadata: {},
metadata: { branchId: rawBranchId },
},
},
});

View File

@@ -85,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,
@@ -245,21 +245,27 @@ export class ClaimRequestManagementV2Controller {
@ApiOperation({
summary: "Sign final claim pricing (owner)",
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. " +
"If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
})
@ApiBody({
description: "Signature file and agreement",
description: "Signature file, agreement, and branch",
schema: {
type: "object",
required: ["sign", "agree"],
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",
},
},
},
})
@@ -285,6 +291,7 @@ export class ClaimRequestManagementV2Controller {
async submitOwnerInsurerApprovalSignV2(
@Param("claimRequestId") claimRequestId: string,
@Body("agree") agree: string | boolean,
@Body("branchId") branchId: string,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
@@ -297,6 +304,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
claimRequestId,
agreed,
typeof branchId === "string" ? branchId : "",
sign,
user.sub,
user,

View File

@@ -82,6 +82,10 @@ 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;

View File

@@ -99,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

@@ -245,6 +245,24 @@ export class ExpertClaimService {
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`. */
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined;
@@ -2226,20 +2244,30 @@ export class ExpertClaimService {
},
'workflow.preLockQueueSnapshot': {
claimStatus: claim.claimStatus,
currentStep:
claim.workflow?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
),
...(claim.workflow?.nextStep != null
? { nextStep: claim.workflow.nextStep }
? {
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
claim.workflow.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
),
}
: {}),
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
history: {
event: 'CLAIM_LOCKED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Claim locked by damage expert ${actor.fullName}`,
type: "CLAIM_LOCKED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: 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: {
history: {
event: 'IN_PERSON_VISIT_REQUESTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: note || 'Expert requested in-person visit',
type: "IN_PERSON_VISIT_REQUESTED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
note: note || "Expert requested in-person visit",
},
},
},
});
@@ -2678,7 +2711,7 @@ export class ExpertClaimService {
claimRequestId,
status: claim.status,
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,
'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
{
'workflow.locked': true,
@@ -2919,6 +2957,34 @@ export class ExpertClaimService {
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). */
private serializeBlameExpertDecisionForClaimDetail(
decision: unknown,
@@ -3025,8 +3091,9 @@ export class ExpertClaimService {
/**
* Persist unlock when claim V2 workflow lock TTL has passed.
* If the expert never submitted a damage reply, restores queue fields from
* `workflow.preLockQueueSnapshot` (or defaults) so the case shows as WAITING_FOR_DAMAGE_EXPERT again.
* When the case was in {@link ClaimCaseStatus.EXPERT_REVIEWING}, restores queue fields from
* `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(
claimRequestId: string,
@@ -3047,7 +3114,6 @@ export class ExpertClaimService {
) {
return false;
}
if (!expiredAt && !lockedAt) return false;
const lockedById = claim.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = {
@@ -3059,12 +3125,10 @@ export class ExpertClaimService {
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = lockedById;
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(String(lockedById));
}
const needsQueueRestore =
claim.status === ClaimCaseStatus.EXPERT_REVIEWING &&
!this.claimHasDamageExpertReply(claim);
const needsQueueRestore = claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const snap = claim.workflow?.preLockQueueSnapshot as
| {
@@ -3075,11 +3139,16 @@ export class ExpertClaimService {
| undefined;
const restoreClaimStatus = snap?.claimStatus ?? ClaimStatus.PENDING;
const restoreCurrent =
snap?.currentStep ?? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
const restoreCurrent = this.normalizeClaimWorkflowStepForSnapshot(
snap?.currentStep,
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
);
const restoreNext =
snap?.nextStep != null
? snap.nextStep
? this.normalizeClaimWorkflowStepForSnapshot(
snap.nextStep,
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
)
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
const lockFieldsUnset = {
@@ -3136,6 +3205,7 @@ export class ExpertClaimService {
actor: any,
): Promise<ClaimDetailV2ResponseDto> {
const actorId = actor.sub;
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const claim = await this.claimCaseDbService.findById(claimRequestId);
@@ -3319,6 +3389,21 @@ export class ExpertClaimService {
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
: 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 {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
@@ -3341,7 +3426,9 @@ export class ExpertClaimService {
fullName: claim.owner.fullName,
}
: undefined,
vehicle: vehiclePayload,
vehicle: vehiclePayload
? this.sanitizeVehicleInquiryForApi(vehiclePayload)
: undefined,
...blameFileContext,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
@@ -3361,8 +3448,9 @@ export class ExpertClaimService {
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
}
: undefined,
objection,
videoCapture,
blameCase: blameCase ?? undefined,
blameCase: blameCaseForApi,
blameExpertDecision,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
@@ -3405,31 +3493,41 @@ export class ExpertClaimService {
: [];
const selectedParts = body.selectedParts as string[];
if (!(claim as any).damage) (claim as any).damage = {};
(claim as any).damage.selectedParts = selectedParts;
const $set: Record<string, unknown> = {
"damage.selectedParts": selectedParts,
};
// Keep damaged-parts captures consistent with updated selection.
const damagedPartsMedia = (claim as any).media?.damagedParts;
if (damagedPartsMedia) {
const selectedSet = new Set(selectedParts);
if (damagedPartsMedia instanceof Map) {
for (const key of Array.from(damagedPartsMedia.keys())) {
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
const m = new Map(damagedPartsMedia as Map<string, unknown>);
for (const key of Array.from(m.keys())) {
if (!selectedSet.has(key)) m.delete(key);
}
$set["media.damagedParts"] = Object.fromEntries(m.entries());
} else {
for (const key of Object.keys(damagedPartsMedia)) {
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
const o = {
...(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 = [];
(claim as any).history.push({
event: "EXPERT_DAMAGED_PARTS_UPDATED",
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: "Damage expert edited selected damaged parts",
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set,
$push: {
history: {
type: "EXPERT_DAMAGED_PARTS_UPDATED",
actor: {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorType: "damage_expert",
},
timestamp: new Date(),
metadata: {
previousSelectedParts: previous,
selectedParts,
@@ -3437,10 +3535,10 @@ export class ExpertClaimService {
expertProfileSnapshot: damagedPartsEditSnapshot,
}),
},
},
},
});
await (claim as any).save();
return {
claimRequestId: claim._id.toString(),
selectedParts,