1
0
forked from Yara724/api

Compare commits

...

6 Commits

Author SHA1 Message Date
SepehrYahyaee
54ae82aa38 YARA-1056 2026-07-11 13:16:14 +03:30
SepehrYahyaee
7a3ddcc7be YARA-937 2026-07-11 12:23:00 +03:30
SepehrYahyaee
da3f57870e YARA-917 2026-07-11 12:00:11 +03:30
SepehrYahyaee
04f51167c2 YARA-1061 2026-07-11 11:38:49 +03:30
SepehrYahyaee
2c8fd3960f YARA-957 2026-07-11 11:25:42 +03:30
SepehrYahyaee
e59058520c YARA-914, YARA-923 2026-07-11 11:16:16 +03:30
10 changed files with 156 additions and 45 deletions

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";
@@ -541,6 +545,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<string, any>,
@@ -554,10 +575,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 } : {}),
};
}
@@ -2982,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.",
);
}
@@ -4801,6 +4837,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 +4907,18 @@ export class ExpertClaimService {
let evaluationForApi: Record<string, unknown> | 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 +4928,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) {

View File

@@ -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(
@@ -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 })

View File

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

View File

@@ -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,
};
}
}

View File

@@ -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;

View File

@@ -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;