From e59058520c19843c10310d9766ad4bedfde2865e Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 11:16:16 +0330 Subject: [PATCH 1/6] YARA-914, YARA-923 --- src/expert-claim/dto/claim-detail-v2.dto.ts | 17 ++-- src/expert-claim/expert-claim.service.ts | 79 ++++++++++++++++--- .../expert-claim.v2.controller.ts | 2 +- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts index a2bd5e5..c6b0e59 100644 --- a/src/expert-claim/dto/claim-detail-v2.dto.ts +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -134,13 +134,16 @@ export class ClaimDetailV2ResponseDto { @ApiPropertyOptional({ description: "Slice of `claim.evaluation` exposed to the damage expert. " + - "`damageExpertReply` / `damageExpertReplyFinal` are returned only while " + - "awaiting factor validation. `ownerInsurerApproval` and " + - "`ownerPricedPartsApproval` are returned whenever they exist on the " + - "claim — each carries `signLink` (resolved from `signDetailId`) so the " + - "front-end can render the user signature directly. Likewise, " + - "`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " + - "expose `signLink` when a user comment signature is present.", + "`damageExpertReply` / `damageExpertReplyFinal` are returned whenever " + + "present, regardless of the current claim status — historical assessment " + + "data remains visible once the expert has submitted it. " + + "`ownerInsurerApproval` and `ownerPricedPartsApproval` are returned " + + "whenever they exist on the claim — each carries `signLink` (resolved " + + "from `signDetailId`) so the front-end can render the user signature " + + "directly. Likewise, `damageExpertReply.userComment` / " + + "`damageExpertReplyFinal.userComment` expose `signLink` when a user " + + "comment signature is present. `priceDrop` is included whenever it " + + "has been saved, not only during the expert review phase.", }) evaluation?: { damageExpertReply?: unknown; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 72d780c..7681b88 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -541,6 +541,23 @@ export class ExpertClaimService { return this.expertVehicleFromPartyVehicle(party?.vehicle); } + /** Extract car name / model from the inquiry snapshot embedded on the claim. */ + private vehicleNamesFromClaimInquiries(claim: any): { + carName?: string; + carModel?: string; + } { + // Claim copies inquiries from blame at creation time. + // Try thirdParty first, then carBody. + const mapped = + claim?.inquiries?.thirdParty?.data?.FIRST?.mapped ?? + claim?.inquiries?.carBody?.data?.FIRST?.mapped ?? + {}; + const carName: string | undefined = + mapped.MapTypNam || mapped.SystemField || undefined; + const carModel: string | undefined = mapped.TypeField || undefined; + return { carName, carModel }; + } + private vehicleForExpertFromClaimAndBlameMap( claim: any, blameById: Map, @@ -554,10 +571,24 @@ export class ExpertClaimService { | undefined { const cv = claim?.vehicle; if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) { + const carType: string | undefined = cv.carType; + // carName/carModel that match the enum value (e.g. "sedan") are invalid — + // the expert may have accidentally copied the carType value into those fields. + // Fall back to the inquiry snapshot for the real brand / variant strings. + let carName: string | undefined = cv.carName; + let carModel: string | undefined = cv.carModel; + if ( + (!carName || carName === carType) || + (!carModel || carModel === carType) + ) { + const fromInquiry = this.vehicleNamesFromClaimInquiries(claim); + if (!carName || carName === carType) carName = fromInquiry.carName; + if (!carModel || carModel === carType) carModel = fromInquiry.carModel; + } return { - carName: cv.carName, - carModel: cv.carModel, - carType: cv.carType, + carName, + carModel, + carType, ...((cv as any).plate ? { plate: (cv as any).plate } : {}), }; } @@ -4801,6 +4832,24 @@ export class ExpertClaimService { if (fromBlame) vehiclePayload = fromBlame; } + // Patch up carName/carModel when they equal carType (invalid — expert copied the enum value). + if (vehiclePayload) { + const carType: string | undefined = vehiclePayload.carType; + if ( + carType && + ((!vehiclePayload.carName || vehiclePayload.carName === carType) || + (!vehiclePayload.carModel || vehiclePayload.carModel === carType)) + ) { + const fromInquiry = this.vehicleNamesFromClaimInquiries(claim); + if (!vehiclePayload.carName || vehiclePayload.carName === carType) { + vehiclePayload = { ...vehiclePayload, carName: fromInquiry.carName }; + } + if (!vehiclePayload.carModel || vehiclePayload.carModel === carType) { + vehiclePayload = { ...vehiclePayload, carModel: fromInquiry.carModel }; + } + } + } + const blameFileContext = blameLean ? this.blameFileContextForExpert(blameLean) : {}; @@ -4853,15 +4902,18 @@ export class ExpertClaimService { let evaluationForApi: Record | undefined; if (enrichedEvaluation) { evaluationForApi = {}; - if (isFactorValidationPending) { - if (enrichedEvaluation.damageExpertReply !== undefined) { - evaluationForApi.damageExpertReply = - enrichedEvaluation.damageExpertReply; - } - if (enrichedEvaluation.damageExpertReplyFinal !== undefined) { - evaluationForApi.damageExpertReplyFinal = - enrichedEvaluation.damageExpertReplyFinal; - } + // damageExpertReply / damageExpertReplyFinal: include whenever present. + // Previously these were gated on isFactorValidationPending, which hid the + // expert assessment once the claim moved to user-side statuses such as + // INSURER_REVIEW_AWAITING_OWNER_SIGN. Historical assessment data should + // always be visible once submitted. + if (enrichedEvaluation.damageExpertReply !== undefined) { + evaluationForApi.damageExpertReply = + enrichedEvaluation.damageExpertReply; + } + if (enrichedEvaluation.damageExpertReplyFinal !== undefined) { + evaluationForApi.damageExpertReplyFinal = + enrichedEvaluation.damageExpertReplyFinal; } if (enrichedEvaluation.ownerInsurerApproval !== undefined) { evaluationForApi.ownerInsurerApproval = @@ -4871,7 +4923,8 @@ export class ExpertClaimService { evaluationForApi.ownerPricedPartsApproval = enrichedEvaluation.ownerPricedPartsApproval; } - if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) { + // priceDrop: include whenever present, not only during the expert phase. + if (enrichedEvaluation.priceDrop !== undefined) { evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; } if (Object.keys(evaluationForApi).length === 0) { diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 44e6f3e..7ddc4a0 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -153,7 +153,7 @@ export class ExpertClaimV2Controller { @ApiOperation({ summary: "Get claim request detail for damage expert", description: - "Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.", + "Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.", }) @ApiParam({ name: "claimRequestId" }) async getClaimDetailV2( From 2c8fd3960f9d84ea2c2d57abff257d76800ee1b5 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 11:25:42 +0330 Subject: [PATCH 2/6] YARA-957 --- src/helpers/claim-details-v2-owner-guidance.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/helpers/claim-details-v2-owner-guidance.ts b/src/helpers/claim-details-v2-owner-guidance.ts index 66c13eb..ba7ce96 100644 --- a/src/helpers/claim-details-v2-owner-guidance.ts +++ b/src/helpers/claim-details-v2-owner-guidance.ts @@ -263,8 +263,6 @@ export function buildClaimDetailsV2OwnerGuidance( shape.mixedFactorAndPrice && !claim.evaluation?.ownerPricedPartsApproval?.signedAt ) { - const ow = objectionWindowForOwner(claim); - const allowObj = ow.pricingEligible; return { phaseKey: "SIGN_PRICED_LINES", headline: "Sign acceptance of priced lines", @@ -278,13 +276,12 @@ export function buildClaimDetailsV2OwnerGuidance( "Multipart sign + agree + branchId", ), ], - objectionAllowed: allowObj, - objectionHint: objectionHintWhen(allowObj), + objectionAllowed, + objectionHint, }; } /** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */ - const allowObj = objectionWindowForOwner(claim).pricingEligible; return { phaseKey: "INSURER_REVIEW_NEEDS_REVISION", headline: "Insurer approval — action pending", @@ -298,13 +295,12 @@ export function buildClaimDetailsV2OwnerGuidance( "Sign if prompted by app state", ), ], - objectionAllowed: allowObj, - objectionHint: objectionHintWhen(allowObj), + objectionAllowed, + objectionHint, }; } if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) { - const allowObj = objectionWindowForOwner(claim).pricingEligible; return { phaseKey: "FINAL_SIGN_OR_REJECT", headline: "Accept or reject final pricing", @@ -323,8 +319,8 @@ export function buildClaimDetailsV2OwnerGuidance( "Dispute priced lines before signing", ), ], - objectionAllowed: allowObj, - objectionHint: objectionHintWhen(allowObj), + objectionAllowed, + objectionHint, }; } } From 04f51167c26efbc1619fad300fe76d5e0e45c4aa Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 11:38:49 +0330 Subject: [PATCH 3/6] YARA-1061 --- src/request-management/inquiry-refresh.service.ts | 3 +-- src/request-management/request-management.service.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/request-management/inquiry-refresh.service.ts b/src/request-management/inquiry-refresh.service.ts index 511a460..3d94ab2 100644 --- a/src/request-management/inquiry-refresh.service.ts +++ b/src/request-management/inquiry-refresh.service.ts @@ -367,8 +367,7 @@ export class InquiryRefreshService { } next.insurance.policyNumber = - mapped.LastCompanyDocumentNumber || - mapped.insuranceNumber || + mapped.PrntPlcyCmpDocNo || next.insurance.policyNumber; next.insurance.company = mapped.companyPersianName || mapped.CompanyName || next.insurance.company; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 2c7411e..0bfb5d2 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1323,7 +1323,7 @@ export class RequestManagementService { }; if (!party.insurance) party.insurance = {} as any; - party.insurance.policyNumber = inquiryMapped?.insuranceNumber; + party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; party.insurance.company = clientName; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.startDate = inquiryMapped?.IssueDate; From da3f57870e801c2eb5f4285d57ff4472a7e1b2a3 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 12:00:11 +0330 Subject: [PATCH 4/6] YARA-917 --- .../expert-insurer.controller.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index ce51129..9d11423 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -99,20 +99,32 @@ export class ExpertInsurerController { @Put("branches/:branchId/status") @ApiParam({ name: "branchId" }) - @ApiQuery({ name: "isActive", type: Boolean }) + @ApiBody({ + schema: { + type: "object", + properties: { isActive: { type: "boolean" } }, + required: ["isActive"], + }, + }) async setBranchStatus( @CurrentUser() insurer, @Param("branchId") branchId: string, - @Query("isActive") isActive: string, + @Body("isActive") isActive: unknown, ) { if (!insurer) { throw new UnauthorizedException("Could not identify the current user."); } - const normalized = String(isActive).trim().toLowerCase(); - if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { - throw new BadRequestException("isActive must be true/false"); + // Accept native boolean (JSON body) or string coercion (legacy query/form usage) + let active: boolean; + if (typeof isActive === "boolean") { + active = isActive; + } else { + const normalized = String(isActive ?? "").trim().toLowerCase(); + if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { + throw new BadRequestException("isActive must be a boolean"); + } + active = ["true", "1", "yes"].includes(normalized); } - const active = ["true", "1", "yes"].includes(normalized); return this.expertInsurerService.setBranchActive( insurer.clientKey, branchId, From 7a3ddcc7beb420c3e1ecaff74eae8ff65ef6dc98 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 12:23:00 +0330 Subject: [PATCH 5/6] YARA-937 --- .../exceptions/business-rule.exception.ts | 25 +++++++++++++++++++ src/expert-claim/expert-claim.service.ts | 7 +++++- .../expert-claim.v2.controller.ts | 15 ++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/expert-claim/exceptions/business-rule.exception.ts diff --git a/src/expert-claim/exceptions/business-rule.exception.ts b/src/expert-claim/exceptions/business-rule.exception.ts new file mode 100644 index 0000000..97c7d7a --- /dev/null +++ b/src/expert-claim/exceptions/business-rule.exception.ts @@ -0,0 +1,25 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; + +/** + * Thrown when a well-formed request violates a business rule that cannot be + * expressed as a generic validation error. Returns HTTP 422 with a structured + * body so the frontend can branch on `errorCode` without string-matching the + * human-readable `message`. + * + * Response body shape: + * ```json + * { "errorCode": "SOME_CODE", "message": "Human-readable explanation." } + * ``` + */ +export class BusinessRuleException extends HttpException { + constructor( + public readonly errorCode: string, + message: string, + ) { + super({ errorCode, message }, HttpStatus.UNPROCESSABLE_ENTITY); + } +} + +export const BusinessErrorCode = { + DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED", +} as const; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 7681b88..bb8b645 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -12,6 +12,10 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { + BusinessErrorCode, + BusinessRuleException, +} from "./exceptions/business-rule.exception"; import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه import { Types } from "mongoose"; import { lastValueFrom } from "rxjs"; @@ -3013,7 +3017,8 @@ export class ExpertClaimService { } if (existingResend?.fulfilledAt) { - throw new BadRequestException( + throw new BusinessRuleException( + BusinessErrorCode.DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED, "The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.", ); } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 7ddc4a0..410bd8b 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -281,7 +281,20 @@ export class ExpertClaimV2Controller { description: "Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " + "Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" + - "**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.", + "**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **422** with `errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED`—the expert may only submit a priced reply or request in-person visit afterward.", + }) + @ApiResponse({ + status: 422, + description: + "Business rule violation: resend limit exceeded. " + + "`errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED` — the owner has already fulfilled a prior resend request for this claim.", + schema: { + example: { + errorCode: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED", + message: + "The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.", + }, + }, }) @ApiParam({ name: "claimRequestId" }) @ApiBody({ type: ClaimSubmitResendV2Dto }) From 54ae82aa389f237f60fb5b25fde568a8fd783fa2 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 13:16:14 +0330 Subject: [PATCH 6/6] YARA-1056 --- src/captcha/captcha-challenge.service.ts | 7 ++++++- src/captcha/entities/schema/captcha-challenge.schema.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/captcha/captcha-challenge.service.ts b/src/captcha/captcha-challenge.service.ts index 7c0574c..e534015 100644 --- a/src/captcha/captcha-challenge.service.ts +++ b/src/captcha/captcha-challenge.service.ts @@ -32,12 +32,17 @@ export class CaptchaChallengeService { this.captchaService.normalizeAnswer(generated.text), ); + // expireAt is the MongoDB TTL sentinel. The TTL reaper fires every ~60 s, so + // setting it equal to expiresAt means Mongo can delete the document up to 60 s + // BEFORE the application-level expiry check runs — causing the intermittent + // "captchaId not found" error under load. Adding a 120 s grace buffer ensures + // the document is always present when verify() runs its own expiresAt check. await this.captchaChallengeDbService.create({ captchaId, answerHash, image: generated.image, expiresAt: generated.expiresAt, - expireAt: new Date(generated.expiresAt), + expireAt: new Date(generated.expiresAt + 120_000), usedAt: null, }); diff --git a/src/captcha/entities/schema/captcha-challenge.schema.ts b/src/captcha/entities/schema/captcha-challenge.schema.ts index ed413e8..5f35e73 100644 --- a/src/captcha/entities/schema/captcha-challenge.schema.ts +++ b/src/captcha/entities/schema/captcha-challenge.schema.ts @@ -10,8 +10,8 @@ export class CaptchaChallenge { @Prop({ required: true, unique: true, index: true }) captchaId: string; - @Prop() - answerHash?: string; + @Prop({ required: true }) + answerHash: string; @Prop({ required: true }) image: string;