forked from Yara724/api
Merge pull request 'YARA-867, YARA-868' (#50) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#50
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`).',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user