1
0
forked from Yara724/api

user owner guidence added + status and steps fixed

This commit is contained in:
Soheil Hajizadeh
2026-05-02 01:50:45 +03:30
parent 908292b0c3
commit e1115b0632
10 changed files with 842 additions and 101 deletions

View File

@@ -84,6 +84,12 @@ import {
normalizeResendPartKeys,
partKeyAllowedForExpertResend,
} from "src/helpers/claim-expert-resend";
import {
classifyV2ExpertPricingParts,
getActiveV2ExpertReply,
objectionDisallowedDueToOutstandingFactorWorkflow,
} from "src/helpers/claim-v2-expert-reply-workflow";
import { buildClaimDetailsV2OwnerGuidance } from "src/helpers/claim-details-v2-owner-guidance";
import {
canonicalizeClaimCarAngleKey,
getClaimCarAngleCaptureBlob,
@@ -2363,9 +2369,30 @@ export class ClaimRequestManagementService {
);
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
throw new BadRequestException(
"Factors can only be uploaded while the insurer-approval phase is pending.",
);
}
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
throw new BadRequestException(
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
"Factors can only be uploaded while the claim needs owner action (claimStatus NEEDS_REVISION).",
);
}
if (claim.workflow?.currentStep !== ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS) {
throw new BadRequestException(
`Repair factors are uploaded at workflow step ${ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS} (after accepting priced lines when applicable). Current: ${String(claim.workflow?.currentStep ?? "")}.`,
);
}
const flowParts = finalReply.parts ?? [];
const pricingShape = classifyV2ExpertPricingParts(flowParts);
if (
pricingShape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
) {
throw new BadRequestException(
"Sign acceptance of the priced repair lines first (`PUT .../owner-insurer-approval/sign` while claimStatus is NEEDS_REVISION at INSURER_REVIEW), then upload factors for factor-needed parts.",
);
}
@@ -2459,7 +2486,11 @@ export class ClaimRequestManagementService {
);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
$set: {
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
"workflow.nextStep": ClaimWorkflowStep.INSURER_REVIEW,
},
$push: {
history: {
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
@@ -5033,8 +5064,10 @@ export class ClaimRequestManagementService {
/**
* V2 API: User objection (ClaimCase + evaluation.objection payload).
*
* Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet.
* Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts.
* Priced-flow: WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW — either NEEDS_REVISION (mixed-priced gate before factor upload)
* or APPROVED (final totals after pricing / factor validation); never while uploading factors or while expert validates.
* Legacy: objection during unpaid expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).
* Post: resets owner signatures when applicable and returns claim to WAITING_FOR_DAMAGE_EXPERT.
*/
async handleUserObjectionV2(
claimRequestId: string,
@@ -5058,26 +5091,26 @@ export class ClaimRequestManagementService {
throw new ForbiddenException('Only the claim owner can submit an objection');
}
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply;
const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal;
if (!hasExpertResend && !hasFirstExpertReply) {
throw new BadRequestException(
'You can only object after the damage expert has submitted an assessment or requested a resend.',
);
}
if (hasFinalExpertReply) {
throw new ConflictException(
'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.',
);
}
if (claimCase.evaluation?.objection?.submittedAt) {
throw new ConflictException('An objection has already been submitted for this claim.');
}
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
const pendingResendGate =
hasExpertResend &&
claimCase.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND &&
claimCase.workflow?.currentStep === ClaimWorkflowStep.USER_EXPERT_RESEND;
if (claimCase.evaluation?.damageExpertReplyFinal) {
throw new ConflictException(
'The expert already submitted the final reply after objection; submit accept/reject via the owner signer.',
);
}
const activeExpert = getActiveV2ExpertReply(
claimCase as unknown as { evaluation?: Record<string, unknown> },
);
const partsIn = body.objectionParts ?? [];
const newPartsIn = body.newParts ?? [];
if (partsIn.length === 0 && newPartsIn.length === 0) {
@@ -5086,6 +5119,61 @@ export class ClaimRequestManagementService {
);
}
let pricingObjectionEligible = false;
if (
claimCase.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
claimCase.workflow?.currentStep === ClaimWorkflowStep.INSURER_REVIEW &&
!claimCase.evaluation?.ownerInsurerApproval?.signedAt
) {
if (objectionDisallowedDueToOutstandingFactorWorkflow(claimCase)) {
throw new BadRequestException(
'Objections are paused while factor files are uploading or the expert is validating repair factors.',
);
}
const shape = classifyV2ExpertPricingParts(activeExpert?.parts ?? []);
const atMixedPartialGate =
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
shape.mixedFactorAndPrice &&
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
const atFinalPricingGate =
claimCase.claimStatus === ClaimStatus.APPROVED;
pricingObjectionEligible = atMixedPartialGate || atFinalPricingGate;
}
if (!(pendingResendGate || pricingObjectionEligible)) {
throw new BadRequestException(
'No open objection window. Use this after a priced insurer-review step without a recorded final approval, ' +
'or while completing an unpaid expert resend.',
);
}
if (!pendingResendGate && pricingObjectionEligible && !activeExpert?.parts?.length) {
throw new BadRequestException(
'A priced assessment is required before you can object.',
);
}
const pricedPartIdSet =
activeExpert?.parts?.length ?
new Set(
activeExpert.parts
.filter((p) => p.factorNeeded !== true)
.map((p) => String((p as { partId?: string }).partId ?? ""))
.filter(Boolean),
)
: new Set<string>();
if (partsIn.length && pricingObjectionEligible) {
for (const op of partsIn) {
if (!pricedPartIdSet.has(String(op.partId))) {
throw new BadRequestException(
`Only priced repair lines can be disputed (${String(op.partId)} is factor-only or unknown).`,
);
}
}
}
const objectionParts = partsIn.map((p) => ({
partId: p.partId,
reason: p.reason,
@@ -5126,6 +5214,10 @@ export class ClaimRequestManagementService {
'evaluation.objection': objectionPayload,
'damage.selectedParts': mergedSelected,
},
$unset: {
'evaluation.ownerPricedPartsApproval': "",
'evaluation.ownerInsurerApproval': "",
},
$push: {
history: {
type: 'USER_OBJECTION_SUBMITTED',
@@ -5227,9 +5319,13 @@ export class ClaimRequestManagementService {
}
/**
* 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).
* V2: Claim owner signature on expert pricing.
*
* Phase 1 (mixed priced + factor-needed lines): WAITING_FOR_INSURER_APPROVAL, NEEDS_REVISION, INSURER_REVIEW,
* owner has not yet accepted priced lines — agreeing records `evaluation.ownerPricedPartsApproval` and moves the
* workflow to OWNER_UPLOAD_FACTOR_DOCUMENTS. Rejecting rejects the whole case.
*
* Phase 2 (final): WAITING_FOR_INSURER_APPROVAL, APPROVED, INSURER_REVIEW — full accept/reject completes the case.
*/
async submitOwnerInsurerApprovalSignV2(
claimRequestId: string,
@@ -5245,6 +5341,7 @@ export class ClaimRequestManagementService {
claimStatus: ClaimStatus;
currentStep?: ClaimWorkflowStep;
accepted: boolean;
phase?: "PRICED_PARTS_FOR_FACTORS" | "FINAL_APPROVAL";
}> {
if (!Types.ObjectId.isValid(claimRequestId)) {
throw new BadRequestException("Invalid claim request id");
@@ -5271,7 +5368,7 @@ export class ClaimRequestManagementService {
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL) {
throw new BadRequestException(
`Claim is not waiting for your approval signature. Current status: ${String(claimCase.status)}`,
`Claim is not waiting for your insurer-approval signature. Current status: ${String(claimCase.status)}`,
);
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.INSURER_REVIEW) {
@@ -5279,21 +5376,41 @@ export class ClaimRequestManagementService {
`Signing is only allowed at workflow step ${ClaimWorkflowStep.INSURER_REVIEW}. Current: ${String(claimCase.workflow?.currentStep ?? "")}`,
);
}
if (claimCase.claimStatus !== ClaimStatus.APPROVED) {
const active =
claimCase.evaluation?.damageExpertReplyFinal ||
claimCase.evaluation?.damageExpertReply;
if (!active?.submittedAt) {
throw new BadRequestException("No expert reply is available to sign off on yet.");
}
const shape = classifyV2ExpertPricingParts(active.parts ?? []);
const pricedBranchReply = {
parts: (active.parts ?? []).filter((p: { factorNeeded?: boolean }) => !p.factorNeeded),
};
const isMixedPartialGate =
shape.mixedFactorAndPrice &&
claimCase.claimStatus === ClaimStatus.NEEDS_REVISION &&
!claimCase.evaluation?.ownerPricedPartsApproval?.signedAt;
const isFinalApprovalGate =
claimCase.claimStatus === ClaimStatus.APPROVED &&
!claimCase.evaluation?.ownerInsurerApproval?.signedAt;
if (!isMixedPartialGate && !isFinalApprovalGate) {
throw new BadRequestException(
`Claim must be insurer-approved before owner signature. Current claimStatus: ${String(claimCase.claimStatus)}`,
"No owner signature action is available at this state. " +
"If repair factors are pending upload, complete the priced-line signature first (when claimStatus is NEEDS_REVISION). " +
"If you already completed the final approval signature, refresh the claim.",
);
}
if (claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("You have already submitted a signature for this claim.");
if (isMixedPartialGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("Claim already has a final approval record; refresh the claim state.");
}
const activeReply =
claimCase.evaluation?.damageExpertReplyFinal ||
claimCase.evaluation?.damageExpertReply;
if (!activeReply?.submittedAt) {
throw new BadRequestException("No expert reply is available to sign off on yet.");
if (isFinalApprovalGate && claimCase.evaluation?.ownerInsurerApproval?.signedAt) {
throw new ConflictException("You have already submitted a signature for this claim.");
}
const rawBranchId = (branchId ?? "").trim();
@@ -5312,9 +5429,10 @@ export class ClaimRequestManagementService {
"This branch does not belong to the insurer for this claim.",
);
}
const branchIdsOnPricing = this.collectBranchIdsFromClaimExpertReply(
activeReply as { parts?: unknown[] },
);
const branchIdsOnPricing =
isMixedPartialGate
? this.collectBranchIdsFromClaimExpertReply(pricedBranchReply)
: this.collectBranchIdsFromClaimExpertReply(active as { parts?: unknown[] });
if (
branchIdsOnPricing.size > 0 &&
!branchIdsOnPricing.has(String(rawBranchId))
@@ -5332,7 +5450,7 @@ export class ClaimRequestManagementService {
} as any);
const signId = new Types.ObjectId(String((signDoc as any)._id));
const signedAt = new Date();
const approvalPayload = {
const scopedApprovalPayload = {
agree,
branchId: new Types.ObjectId(rawBranchId),
signDetailId: signId,
@@ -5345,12 +5463,73 @@ export class ClaimRequestManagementService {
actorType: "user",
};
/** Mixed reply: priced lines acceptance before factor uploads */
if (isMixedPartialGate) {
if (!agree) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
"workflow.nextStep": null,
},
$push: {
history: {
type: "OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS",
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,
phase: "PRICED_PARTS_FOR_FACTORS",
};
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
claimStatus: ClaimStatus.NEEDS_REVISION,
"evaluation.ownerPricedPartsApproval": scopedApprovalPayload,
"workflow.currentStep": ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_COST_EVALUATION,
},
$push: {
history: {
type: "OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD",
actor: historyActor,
timestamp: signedAt,
metadata: { branchId: rawBranchId },
},
},
});
return {
message:
"Priced repair lines accepted. Upload repair factor files for each factor-needed part; the claim will return to the damage expert for cost validation.",
claimRequestId,
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
claimStatus: ClaimStatus.NEEDS_REVISION,
currentStep: ClaimWorkflowStep.OWNER_UPLOAD_FACTOR_DOCUMENTS,
accepted: true,
phase: "PRICED_PARTS_FOR_FACTORS",
};
}
/** Final accept / reject (no outstanding factor gate at this step) */
if (!agree) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.REJECTED,
claimStatus: ClaimStatus.REJECTED,
"evaluation.ownerInsurerApproval": approvalPayload,
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
"workflow.nextStep": null,
},
$push: {
@@ -5370,6 +5549,7 @@ export class ClaimRequestManagementService {
claimStatus: ClaimStatus.REJECTED,
currentStep: claimCase.workflow?.currentStep,
accepted: false,
phase: "FINAL_APPROVAL",
};
}
@@ -5377,7 +5557,7 @@ export class ClaimRequestManagementService {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
"evaluation.ownerInsurerApproval": approvalPayload,
"evaluation.ownerInsurerApproval": scopedApprovalPayload,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
@@ -5399,6 +5579,7 @@ export class ClaimRequestManagementService {
claimStatus: ClaimStatus.APPROVED,
currentStep: ClaimWorkflowStep.CLAIM_COMPLETED,
accepted: true,
phase: "FINAL_APPROVAL",
};
}
@@ -5458,7 +5639,8 @@ export class ClaimRequestManagementService {
}
/**
* V2 API: Get claim details by ID (owner only)
* V2 API: Get claim details by ID (owner — or FIELD_EXPERT with enriched money/rating fields).
* Owners receive `ownerGuidance` computed from workflow state.
*/
async getClaimDetailsV2(
claimRequestId: string,
@@ -5618,6 +5800,14 @@ export class ClaimRequestManagementService {
damageExpertReplyFinal: mappedEvaluation.damageExpertReplyFinal,
}
: undefined,
...(!isExpertViewer
? {
ownerGuidance: buildClaimDetailsV2OwnerGuidance(
claim,
claim._id.toString(),
),
}
: {}),
...(isExpertViewer
? {
userRating: claim.userRating