1
0
forked from Yara724/api

Merge pull request 'YARA-885, + fixes' (#84) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#84
This commit is contained in:
2026-05-25 14:15:37 +03:30
16 changed files with 695 additions and 162 deletions

View File

@@ -5,7 +5,9 @@ import {
UserAuthErrorCode,
throwUserAuthError,
} from "src/auth/auth-services/user-auth-error";
import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema";
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
import { normalizeIranMobile } from "src/helpers/iran-mobile";
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
@@ -20,6 +22,8 @@ export class UserLinkAccessService {
private readonly blameRequestModel: Model<BlameRequest>,
@InjectModel(ClaimRequestManagementModel.name)
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
@InjectModel(ClaimCase.name)
private readonly claimCaseModel: Model<ClaimCase>,
private readonly userDbService: UserDbService,
) {}
@@ -39,10 +43,12 @@ export class UserLinkAccessService {
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
}
const normalizedMobile = this.normalizePhone(params.mobile);
const normalizedMobile = normalizeIranMobile(params.mobile);
if (
!normalizedMobile ||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
!allowedMobiles.some(
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
)
) {
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
}
@@ -58,15 +64,18 @@ export class UserLinkAccessService {
const context = this.normalizeContext(linkContext);
const allowedMobiles = new Set<string>();
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
this.requestManagementModel.findById(id).lean().exec(),
this.blameRequestModel.findById(id).lean().exec(),
this.claimRequestManagementModel.findById(id).lean().exec(),
]);
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
await Promise.all([
this.requestManagementModel.findById(id).lean().exec(),
this.blameRequestModel.findById(id).lean().exec(),
this.claimRequestManagementModel.findById(id).lean().exec(),
this.claimCaseModel.findById(id).lean().exec(),
]);
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
await this.addClaimOwnerPhone(allowedMobiles, claimRequest);
await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
return Array.from(allowedMobiles);
}
@@ -119,7 +128,7 @@ export class UserLinkAccessService {
}
}
private async addClaimOwnerPhone(
private async addLegacyClaimOwnerPhone(
allowedMobiles: Set<string>,
claimRequest: any,
) {
@@ -146,29 +155,51 @@ export class UserLinkAccessService {
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
}
}
/** V2 `claimCases` — token in `/caseClaim?token=...` SMS links. */
private async addClaimCaseOwnerPhone(
allowedMobiles: Set<string>,
claimCase: any,
) {
if (!claimCase?.owner?.userId) return;
const ownerUserIdText = String(claimCase.owner.userId);
if (claimCase.blameRequestId) {
const blameRequest = await this.blameRequestModel
.findById(claimCase.blameRequestId)
.lean()
.exec();
const ownerParty = (blameRequest?.parties || []).find(
(party: any) =>
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
);
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
}
if (Types.ObjectId.isValid(ownerUserIdText)) {
const user = await this.userDbService.findOne({
_id: new Types.ObjectId(ownerUserIdText),
});
this.addPhone(allowedMobiles, user?.mobile);
this.addPhone(allowedMobiles, user?.username);
}
}
private addPhone(allowedMobiles: Set<string>, phone?: string) {
const normalized = this.normalizePhone(phone);
const normalized = normalizeIranMobile(phone);
if (normalized) allowedMobiles.add(normalized);
}
private normalizePhone(phone?: string): string | undefined {
if (!phone) return undefined;
const digits = String(phone).replace(/\D/g, "");
if (!digits) return undefined;
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
if (digits.startsWith("98") && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
return digits;
}
private normalizeContext(linkContext?: string): string | undefined {
return linkContext?.trim().toUpperCase();
const ctx = linkContext?.trim().toUpperCase();
if (!ctx) return undefined;
if (ctx === "USER" || ctx === "USER1") return "FIRST";
if (ctx === "USER2") return "SECOND";
if (ctx === "CASECLAIM" || ctx === "CLAIM") return undefined;
return ctx;
}
private matchesRoleContext(context: string, role?: string): boolean {

View File

@@ -7,6 +7,15 @@ import {
} from "src/auth/auth-services/user-auth-error";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
import {
buildUserLookupByPhone,
normalizeIranMobile,
} from "src/helpers/iran-mobile";
import {
computeOtpExpireMs,
isOtpExpiryActive,
readOtpExpireMinutesFromEnv,
} from "src/helpers/user-otp-expiry";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
@@ -17,7 +26,6 @@ export interface LinkBinding {
linkContext?: string;
}
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable()
export class UserAuthService {
private readonly logger = new Logger(UserAuthService.name);
@@ -36,18 +44,21 @@ export class UserAuthService {
pass: string,
binding: LinkBinding = {},
): Promise<any> {
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
await this.userLinkAccessService.assertMobileAllowed({
mobile: username,
mobile: canonicalMobile,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
});
const user = await this.userDbService.findOne({ username });
const user = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
const now = new Date().getTime();
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
if (user.otpExpire < now) {
if (!isOtpExpiryActive(user.otpExpire)) {
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
}
if (await this.hashService.compare(pass, user.otp)) {
@@ -57,9 +68,10 @@ export class UserAuthService {
}
async login(user: any) {
const userId = String(user._id ?? user.id ?? "");
const payload = {
username: user.username,
sub: user.id,
sub: userId,
role: "user",
};
const accToken = this.jwtService.sign(payload, {
@@ -70,10 +82,11 @@ export class UserAuthService {
{
tokens: { token: accToken },
otp: null,
otpExpire: 0,
},
);
return {
userId: user._id,
userId,
access_token: accToken,
};
}
@@ -82,29 +95,31 @@ export class UserAuthService {
mobile: string,
binding: LinkBinding = {},
): Promise<LoginDtoRs> {
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
if (!canonicalMobile) {
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
}
await this.userLinkAccessService.assertMobileAllowed({
mobile,
mobile: canonicalMobile,
linkToken: binding.linkToken,
linkContext: binding.linkContext,
});
const userExist = await this.userDbService.findOne({
mobile,
});
const userExist = await this.userDbService.findOne(
buildUserLookupByPhone(canonicalMobile),
);
const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
const expireMinutes = readOtpExpireMinutesFromEnv();
const nowMs = Date.now();
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
if (!userExist) {
await this.smsSender(otp, mobile);
/// create otp request
await this.smsSender(otp, canonicalMobile);
const newUser = await this.userDbService.createUser({
mobile,
username: mobile,
mobile: canonicalMobile,
username: canonicalMobile,
otp: hashOtp,
tokens: {
token: "",
@@ -122,22 +137,22 @@ export class UserAuthService {
});
return new LoginDtoRs(newUser);
}
if (userExist) {
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) {
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
}
await this.smsSender(otp, mobile);
const updateTokens = await this.userDbService.findOneAndUpdate(
{
username: userExist.username,
},
{
otp: hashOtp,
otpExpire,
},
);
if (updateTokens) return new LoginDtoRs(userExist);
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
}
await this.smsSender(otp, canonicalMobile);
await this.userDbService.findOneAndUpdate(
buildUserLookupByPhone(canonicalMobile),
{
otp: hashOtp,
otpExpire,
mobile: canonicalMobile,
username: userExist.username || canonicalMobile,
},
);
return new LoginDtoRs(userExist);
}
private async smsSender(otp: string, mobile: string) {

View File

@@ -9,6 +9,10 @@ import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.con
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
import {
ClaimCase,
ClaimCaseSchema,
} from "src/claim-request-management/entites/schema/claim-cases.schema";
import {
ClaimRequestManagementModel,
ClaimRequestManagementSchema,
@@ -44,6 +48,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema,
},
{ name: ClaimCase.name, schema: ClaimCaseSchema },
]),
JwtModule.register({
signOptions: { expiresIn: "1h" },

View File

@@ -35,7 +35,7 @@ export class CaptchaChallengeService {
return {
captchaId,
text: generated.text,
text: generated.text, /* TODO: REMOVE THIS FOR PROD*/
image: generated.image,
expiresAt: generated.expiresAt,
};

View File

@@ -84,6 +84,7 @@ import { UserRatingDto } from "./dto/user-rating.dto";
import {
canFinalizeExpertResend,
documentKeyAllowedForExpertResend,
mapExpertResendCarPartsForClient,
normalizeResendDocumentKeys,
normalizeResendPartKeys,
partKeyAllowedForExpertResend,
@@ -4753,7 +4754,11 @@ export class ClaimRequestManagementService {
}
if (
normalizeResendDocumentKeys(r.resendDocuments).length > 0 ||
normalizeResendPartKeys(r.resendCarParts).length > 0
normalizeResendPartKeys(
r.resendCarParts,
claimCase.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
claimCase.damage?.selectedParts,
).length > 0
) {
throw new BadRequestException(
"Upload all requested documents and part photos before completing the resend step.",
@@ -6142,6 +6147,8 @@ export class ClaimRequestManagementService {
const damagedPartsData = claim.media?.damagedParts as any;
const resendPartKeys = normalizeResendPartKeys(
claim.evaluation?.damageExpertResend?.resendCarParts,
carTypeDetails,
claim.damage?.selectedParts,
);
const displayParts = [...selectedNormDetails];
const seenDetailKeys = new Set(
@@ -6190,7 +6197,12 @@ export class ClaimRequestManagementService {
? {
resendDescription: er.resendDescription,
resendDocuments: normalizeResendDocumentKeys(er.resendDocuments),
resendCarParts: Array.isArray(er.resendCarParts) ? er.resendCarParts : [],
resendCarParts: mapExpertResendCarPartsForClient(er.resendCarParts, {
carType: carTypeDetails,
selectedParts: claim.damage?.selectedParts,
damagedPartsData: claim.media?.damagedParts,
buildUrl: (path) => buildFileLink(path),
}),
fulfilledAt: er.fulfilledAt,
}
: undefined;

View File

@@ -67,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional({ type: [String] })
resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] })
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
@ApiPropertyOptional({
description:
"Damaged parts the expert asked to re-capture (same shape as damagedParts: id, name, side, label_fa, catalogKey, captured, url).",
type: [Object],
})
resendCarParts?: Array<{
id?: number | null;
name?: string;
side?: string;
label_fa?: string;
label_en?: string;
catalogKey?: string;
key?: string;
captured?: boolean;
url?: string;
path?: string;
fileName?: string;
}>;
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
fulfilledAt?: Date;

View File

@@ -66,6 +66,13 @@ export class ClaimWorkflow {
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
/**
* First damage expert who called review assign; kept after the 15m lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ClaimActorLockSchema })
assignedForReviewBy?: ClaimActorLock;
}
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -28,6 +28,12 @@ import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
blameWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import {
@@ -279,6 +285,11 @@ export class ExpertBlameService {
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
// Adding extra fields for front-end
buckets["PENDING_ON_USER"] = buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] + buckets[CaseStatus.WAITING_FOR_SIGNATURES];
buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT];
return buckets;
}
@@ -536,10 +547,18 @@ export class ExpertBlameService {
);
}
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
request.workflow,
);
const expireSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: { "workflow.locked": false },
$set: expireSet,
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
@@ -1051,24 +1070,29 @@ export class ExpertBlameService {
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
request.workflow,
request.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
request.workflow,
),
);
}
const lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: lockedById,
actorName: request.workflow?.lockedBy?.actorName,
lockedAt: request.workflow?.lockedAt
? new Date(request.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
@@ -1084,17 +1108,21 @@ export class ExpertBlameService {
const now = new Date();
const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
const lockUpdate = {
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorRole: "expert" as const,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const lockUpdate: {
$set: Record<string, unknown>;
$push: Record<string, unknown>;
} = {
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
"workflow.lockedBy": {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
"workflow.lockedBy": lockedByPayload,
},
$push: {
history: {
@@ -1109,16 +1137,30 @@ export class ExpertBlameService {
},
},
};
if (!request.workflow?.assignedForReviewBy) {
lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload;
}
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
status: CaseStatus.WAITING_FOR_EXPERT,
blameStatus: BlameStatus.DISAGREEMENT,
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
@@ -1135,6 +1177,19 @@ export class ExpertBlameService {
if (!updated) {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const latest = await this.blameRequestDbService.findById(requestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
}
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
@@ -1142,18 +1197,9 @@ export class ExpertBlameService {
otherId &&
otherId !== actor.sub
) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: otherId,
actorName: latest.workflow?.lockedBy?.actorName,
lockedAt: latest.workflow?.lockedAt
? new Date(latest.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,

View File

@@ -35,6 +35,12 @@ import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
claimWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
@@ -108,6 +114,7 @@ import {
sanitizeDamageSelectedPartV2,
type DamageSelectedPartV2,
} from "src/helpers/outer-damage-parts";
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
@@ -2375,24 +2382,30 @@ export class ExpertClaimService {
});
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
claim.workflow,
claim.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
claim.workflow,
),
);
}
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
claim.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: lockedById,
actorName: claim.workflow?.lockedBy?.actorName,
lockedAt: claim.workflow?.lockedAt
? new Date(claim.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
@@ -2406,21 +2419,22 @@ export class ExpertClaimService {
};
}
const expertOid = new Types.ObjectId(actor.sub);
const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName,
actorRole: "damage_expert" as const,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const baseLockUpdate: Record<string, unknown> = {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": expiredAt,
"workflow.lockedBy": {
actorId: expertOid,
actorName: actor.fullName,
actorRole: "damage_expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
"workflow.lockedBy": lockedByPayload,
"workflow.preLockQueueSnapshot": {
claimStatus: claim.claimStatus,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
@@ -2455,6 +2469,10 @@ export class ExpertClaimService {
},
};
if (!claim.workflow?.assignedForReviewBy) {
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
}
const lockUpdate: Record<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
@@ -2466,11 +2484,22 @@ export class ExpertClaimService {
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId),
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
@@ -2497,6 +2526,19 @@ export class ExpertClaimService {
if (!updated) {
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const latest = await this.claimCaseDbService.findById(claimRequestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
}
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
@@ -2504,18 +2546,9 @@ export class ExpertClaimService {
otherId &&
otherId !== actor.sub
) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: otherId,
actorName: latest.workflow?.lockedBy?.actorName,
lockedAt: latest.workflow?.lockedAt
? new Date(latest.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,
@@ -2680,19 +2713,12 @@ export class ExpertClaimService {
}
const uniqueDocs = [...new Set(docs)];
const normalizedParts = (reply.resendCarParts ?? []).map((p) => {
const row: Record<string, unknown> = {
key: p.key,
label_fa: p.label_fa,
label_en: p.label_en,
captured: false,
};
const id = (p as { id?: number }).id;
if (typeof id === "number" && Number.isFinite(id)) {
row.id = Math.trunc(id);
}
return row;
});
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const normalizedParts = normalizeResendCarPartsForStorage(
reply.resendCarParts,
carType,
claim.damage?.selectedParts,
);
const desc = String(reply.resendDescription ?? "").trim();
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
@@ -3195,6 +3221,11 @@ export class ExpertClaimService {
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
// Add new calculated fields for front-end
buckets["PENDING_ON_USER"] = buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] + buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] + buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] + buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] + buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING];
buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT]
return buckets;
}
@@ -3652,10 +3683,18 @@ export class ExpertClaimService {
"workflow.preLockQueueSnapshot": "",
};
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
claim.workflow,
);
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const update = needsQueueRestore
? {
$set: {
"workflow.locked": false,
...expireLockSet,
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: restoreClaimStatus,
"workflow.currentStep": restoreCurrent,
@@ -3664,7 +3703,7 @@ export class ExpertClaimService {
$unset: lockFieldsUnset,
}
: {
$set: { "workflow.locked": false },
$set: expireLockSet,
$unset: lockFieldsUnset,
};

View File

@@ -1,10 +1,30 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
import { normalizeDamageSelectedParts } from "./outer-damage-parts";
import {
catalogLikeKeyFromPart,
DamageSelectedPartV2,
normalizeDamageSelectedParts,
partLookupKey,
} from "./outer-damage-parts";
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export type ExpertResendCarPartV2 = {
id?: number | null;
name: string;
side: string;
label_fa: string;
label_en?: string;
catalogKey?: string;
/** Same as `catalogKey` — kept for clients that use `key` on capture/resend APIs. */
key?: string;
captured: boolean;
url?: string;
path?: string;
fileName?: string;
};
export function resendRequestHasPayload(r: {
resendDescription?: string;
resendDocuments?: unknown[];
@@ -34,18 +54,161 @@ export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string
return [...new Set(keys)];
}
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
/** Capture / finalize lookup keys for expert-requested damaged parts. */
export function normalizeResendPartKeys(
parts: unknown[] | undefined,
carType?: ClaimVehicleTypeV2,
selectedParts?: unknown,
): string[] {
if (!Array.isArray(parts)) return [];
const selectedNorm = normalizeDamageSelectedParts(
selectedParts,
carType,
);
const keys: string[] = [];
for (const p of parts) {
const enriched =
normalizeDamageSelectedParts([p], carType)[0] ||
matchResendPartFromSelected(p, selectedNorm);
if (enriched) {
const ck =
enriched.catalogKey?.trim() ||
catalogLikeKeyFromPart(enriched);
if (ck) keys.push(ck);
continue;
}
if (typeof p === "string" && p.trim()) keys.push(p.trim());
else if (p && typeof p === "object" && "key" in (p as object)) {
const k = String((p as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
return [...new Set(keys)];
}
function matchResendPartFromSelected(
raw: unknown,
selectedNorm: DamageSelectedPartV2[],
): DamageSelectedPartV2 | undefined {
if (!raw || typeof raw !== "object" || !selectedNorm.length) return undefined;
const o = raw as { id?: number };
if (typeof o.id === "number" && Number.isFinite(o.id)) {
return selectedNorm.find((sp) => sp.id === Math.trunc(o.id));
}
return undefined;
}
function resolveResendPartRow(
raw: unknown,
carType?: ClaimVehicleTypeV2,
selectedNorm?: DamageSelectedPartV2[],
): DamageSelectedPartV2 {
const fromRaw = normalizeDamageSelectedParts([raw], carType)[0];
if (fromRaw?.label_fa && fromRaw.name) return fromRaw;
const fromSelected = matchResendPartFromSelected(raw, selectedNorm ?? []);
if (fromSelected) return fromSelected;
if (fromRaw) return fromRaw;
const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const fallbackKey =
typeof o.key === "string" && o.key.trim()
? o.key.trim()
: typeof raw === "string"
? String(raw).trim()
: "unknown";
return {
id: typeof o.id === "number" ? Math.trunc(o.id) : null,
name: typeof o.name === "string" ? o.name : fallbackKey,
side: typeof o.side === "string" ? o.side : "",
label_fa:
typeof o.label_fa === "string" && o.label_fa.trim()
? o.label_fa.trim()
: fallbackKey,
catalogKey:
typeof o.catalogKey === "string" ? o.catalogKey : undefined,
};
}
/** Enrich stored resend rows for claim detail / capture UIs (label_fa, catalogKey, capture state). */
export function mapExpertResendCarPartsForClient(
rawParts: unknown[] | undefined,
options: {
carType?: ClaimVehicleTypeV2;
selectedParts?: unknown;
damagedPartsData?: unknown;
buildUrl?: (path: string) => string;
} = {},
): ExpertResendCarPartV2[] {
if (!Array.isArray(rawParts) || rawParts.length === 0) return [];
const selectedNorm = normalizeDamageSelectedParts(
options.selectedParts,
options.carType,
);
return rawParts.map((raw) => {
const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, options.carType, selectedNorm);
const catalogKey =
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const captureKey = catalogKey || partLookupKey(sp);
const cap = getDamagedPartCaptureBlob(
options.damagedPartsData,
captureKey,
selectedNorm,
) as { url?: string; path?: string; fileName?: string } | undefined;
const capturedFromStore =
typeof stored.captured === "boolean" ? stored.captured : undefined;
const url =
cap?.url ||
(cap?.path && options.buildUrl ? options.buildUrl(cap.path) : undefined);
return {
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
...(typeof stored.label_en === "string" && stored.label_en.trim()
? { label_en: stored.label_en.trim() }
: {}),
catalogKey,
key: catalogKey,
captured: capturedFromStore ?? !!cap,
...(url ? { url } : {}),
...(cap?.path ? { path: cap.path } : {}),
...(cap?.fileName ? { fileName: cap.fileName } : {}),
};
});
}
/** Persistable snapshot for `evaluation.damageExpertResend.resendCarParts`. */
export function normalizeResendCarPartsForStorage(
rawParts: unknown[] | undefined,
carType?: ClaimVehicleTypeV2,
selectedParts?: unknown,
): Array<Record<string, unknown>> {
if (!Array.isArray(rawParts)) return [];
const selectedNorm = normalizeDamageSelectedParts(selectedParts, carType);
return rawParts.map((raw) => {
const stored =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const sp = resolveResendPartRow(raw, carType, selectedNorm);
const catalogKey =
sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp);
const row: Record<string, unknown> = {
id: sp.id,
name: sp.name,
side: sp.side,
label_fa: sp.label_fa,
catalogKey,
key: catalogKey,
captured: false,
};
if (typeof stored.label_en === "string" && stored.label_en.trim()) {
row.label_en = stored.label_en.trim();
}
return row;
});
}
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
@@ -62,9 +225,10 @@ export function isRequiredDocumentUploaded(claim: any, documentKey: string): boo
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts;
if (!data) return false;
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const norm = normalizeDamageSelectedParts(
claim?.damage?.selectedParts,
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
carType,
claim?.damage?.selectedOuterParts,
);
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
@@ -82,8 +246,13 @@ export function canFinalizeExpertResend(claim: any): boolean {
const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
const partKeys = normalizeResendPartKeys(r.resendCarParts);
const partKeys = normalizeResendPartKeys(
r.resendCarParts,
carType,
claim?.damage?.selectedParts,
);
if (docKeys.length === 0 && partKeys.length === 0) {
return String(r.resendDescription || "").trim() !== "";
@@ -113,6 +282,13 @@ export function documentKeyAllowedForExpertResend(
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const allowed = new Set(
normalizeResendPartKeys(
r.resendCarParts,
carType,
claim?.damage?.selectedParts,
),
);
return allowed.has(partKey);
}

View File

@@ -3,8 +3,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
CaseStatus.OPEN,
CaseStatus.WAITING_FOR_SECOND_PARTY,
// CaseStatus.OPEN,
// CaseStatus.WAITING_FOR_SECOND_PARTY,
]);
/**

View File

@@ -0,0 +1,77 @@
import type { ExpertFileAssignStatus } from "src/common/dto/expert-file-assign-result.dto";
export const BLAME_REVIEW_ASSIGNED_HISTORY_TYPE = "BLAME_ASSIGNED";
export const CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE = "CLAIM_ASSIGNED";
type WorkflowAssigneeRef = {
assignedForReviewBy?: { actorId?: unknown; actorName?: string };
lockedBy?: { actorId?: unknown; actorName?: string };
lockedAt?: Date | string;
};
type HistoryAssigneeRef = {
type?: string;
actor?: { actorId?: unknown };
timestamp?: Date | string;
};
/** Expert who first assigned via the review assign endpoint (survives lock TTL expiry). */
export function resolvePersistentReviewAssigneeId(
workflow: WorkflowAssigneeRef | undefined,
history: HistoryAssigneeRef[] | undefined,
assignedHistoryType: string,
): string {
const fromField = workflow?.assignedForReviewBy?.actorId;
if (fromField != null && String(fromField).length > 0) {
return String(fromField);
}
const fromHistory = history?.find(
(h) => h.type === assignedHistoryType,
)?.actor?.actorId;
if (fromHistory != null && String(fromHistory).length > 0) {
return String(fromHistory);
}
return "";
}
export function buildExpertAssignConflictForOtherReviewer(
assigneeId: string,
workflow?: WorkflowAssigneeRef,
): {
success: false;
status: ExpertFileAssignStatus;
message: string;
lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
} {
const assignee =
workflow?.assignedForReviewBy ?? workflow?.lockedBy;
const lockedAt = workflow?.lockedAt;
return {
success: false,
status: "locked",
message: "Request is already being answered by another expert",
lockedBy: {
actorId: assigneeId,
actorName: assignee?.actorName,
lockedAt: lockedAt
? new Date(lockedAt as Date).toISOString()
: undefined,
},
};
}
/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */
export function blameWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
if (workflow?.assignedForReviewBy) {
return undefined;
}
return workflow?.lockedBy;
}
export function claimWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
return blameWorkflowAssigneeToPersistOnLockExpiry(workflow);
}

View File

@@ -0,0 +1,46 @@
import { FilterQuery } from "mongoose";
import { UserModel } from "src/users/entities/schema/user.schema";
/** Canonical Iranian mobile: leading 0 + 10 digits (e.g. 09123456789). */
export function normalizeIranMobile(phone?: string): string | undefined {
if (!phone) return undefined;
const digits = String(phone).replace(/\D/g, "");
if (!digits) return undefined;
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
if (digits.startsWith("98") && digits.length === 12) {
return `0${digits.slice(2)}`;
}
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
if (digits.length === 11 && digits.startsWith("0")) return digits;
return digits;
}
/** Variants that may exist on legacy user rows (username/mobile). */
export function iranMobileLookupVariants(phone?: string): string[] {
const raw = phone?.trim();
const canonical = normalizeIranMobile(raw);
const variants = new Set<string>();
if (raw) variants.add(raw);
if (canonical) {
variants.add(canonical);
if (canonical.startsWith("0") && canonical.length === 11) {
variants.add(canonical.slice(1));
variants.add(`98${canonical.slice(1)}`);
variants.add(`0098${canonical.slice(1)}`);
}
}
return Array.from(variants).filter(Boolean);
}
export function buildUserLookupByPhone(
phone: string,
): FilterQuery<UserModel> {
const variants = iranMobileLookupVariants(phone);
if (variants.length === 0) {
return { username: phone };
}
return {
$or: variants.flatMap((v) => [{ username: v }, { mobile: v }]),
};
}

View File

@@ -301,6 +301,19 @@ export function normalizeDamageSelectedParts(
}
if (typeof el === "object") {
const o = el as Record<string, unknown>;
const idOnly = toNum(o.id);
const hasName = typeof o.name === "string" && o.name.trim();
const hasKey = typeof o.key === "string" && String(o.key).trim();
if (idOnly != null && !hasName && !hasKey) {
let catItem: OuterPartCatalogItem | undefined;
if (catalog) catItem = catalog.find((c) => c.id === idOnly);
if (!catItem) catItem = CATALOG_ITEM_BY_ID.get(idOnly);
if (catItem) {
const list = catalog ?? outerCatalogListForItem(catItem);
out.push(catalogItemToSelectedPart(catItem, list));
continue;
}
}
if (typeof o.name === "string" && o.name.trim()) {
let name = o.name.trim();
const inner = splitCatalogKeyToNameAndSide(name);

View File

@@ -0,0 +1,43 @@
/** OTP expiry is stored as epoch milliseconds on user documents. */
export function otpExpireToEpochMs(value: unknown): number {
if (value == null) return 0;
if (typeof value === "number" && Number.isFinite(value)) {
if (value >= 1_000_000_000_000) return value;
if (value >= 1_000_000_000 && value < 1_000_000_000_000) {
return value * 1000;
}
return value;
}
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === "string") {
const asNum = Number(value);
if (Number.isFinite(asNum)) return asNum;
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
export function isOtpExpiryActive(
otpExpire: unknown,
nowMs: number = Date.now(),
): boolean {
const exp = otpExpireToEpochMs(otpExpire);
return exp > nowMs;
}
export function computeOtpExpireMs(
expireMinutes: number,
nowMs: number = Date.now(),
): number {
const minutes =
Number.isFinite(expireMinutes) && expireMinutes > 0 ? expireMinutes : 2;
return nowMs + minutes * 60 * 1000;
}
export function readOtpExpireMinutesFromEnv(): number {
const raw = Number(process.env.EXP_OTP_TIME ?? "2");
return Number.isFinite(raw) && raw > 0 ? raw : 2;
}

View File

@@ -48,5 +48,12 @@ export class Workflow {
@Prop({ type: ActorLockSchema })
lockedBy?: ActorLock;
/**
* First expert who called review assign; kept after the 15m workflow lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ActorLockSchema })
assignedForReviewBy?: ActorLock;
}
export const WorkflowSchema = SchemaFactory.createForClass(Workflow);