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:
@@ -5,7 +5,9 @@ import {
|
|||||||
UserAuthErrorCode,
|
UserAuthErrorCode,
|
||||||
throwUserAuthError,
|
throwUserAuthError,
|
||||||
} from "src/auth/auth-services/user-auth-error";
|
} 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 { 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 { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
|
||||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||||
@@ -20,6 +22,8 @@ export class UserLinkAccessService {
|
|||||||
private readonly blameRequestModel: Model<BlameRequest>,
|
private readonly blameRequestModel: Model<BlameRequest>,
|
||||||
@InjectModel(ClaimRequestManagementModel.name)
|
@InjectModel(ClaimRequestManagementModel.name)
|
||||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||||
|
@InjectModel(ClaimCase.name)
|
||||||
|
private readonly claimCaseModel: Model<ClaimCase>,
|
||||||
private readonly userDbService: UserDbService,
|
private readonly userDbService: UserDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -39,10 +43,12 @@ export class UserLinkAccessService {
|
|||||||
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedMobile = this.normalizePhone(params.mobile);
|
const normalizedMobile = normalizeIranMobile(params.mobile);
|
||||||
if (
|
if (
|
||||||
!normalizedMobile ||
|
!normalizedMobile ||
|
||||||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
|
!allowedMobiles.some(
|
||||||
|
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
||||||
}
|
}
|
||||||
@@ -58,15 +64,18 @@ export class UserLinkAccessService {
|
|||||||
const context = this.normalizeContext(linkContext);
|
const context = this.normalizeContext(linkContext);
|
||||||
const allowedMobiles = new Set<string>();
|
const allowedMobiles = new Set<string>();
|
||||||
|
|
||||||
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
|
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
|
||||||
|
await Promise.all([
|
||||||
this.requestManagementModel.findById(id).lean().exec(),
|
this.requestManagementModel.findById(id).lean().exec(),
|
||||||
this.blameRequestModel.findById(id).lean().exec(),
|
this.blameRequestModel.findById(id).lean().exec(),
|
||||||
this.claimRequestManagementModel.findById(id).lean().exec(),
|
this.claimRequestManagementModel.findById(id).lean().exec(),
|
||||||
|
this.claimCaseModel.findById(id).lean().exec(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
|
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
|
||||||
this.addBlameRequestPhones(allowedMobiles, blameRequest, 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);
|
return Array.from(allowedMobiles);
|
||||||
}
|
}
|
||||||
@@ -119,7 +128,7 @@ export class UserLinkAccessService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addClaimOwnerPhone(
|
private async addLegacyClaimOwnerPhone(
|
||||||
allowedMobiles: Set<string>,
|
allowedMobiles: Set<string>,
|
||||||
claimRequest: any,
|
claimRequest: any,
|
||||||
) {
|
) {
|
||||||
@@ -146,29 +155,51 @@ export class UserLinkAccessService {
|
|||||||
_id: new Types.ObjectId(ownerUserIdText),
|
_id: new Types.ObjectId(ownerUserIdText),
|
||||||
});
|
});
|
||||||
this.addPhone(allowedMobiles, user?.mobile);
|
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) {
|
private addPhone(allowedMobiles: Set<string>, phone?: string) {
|
||||||
const normalized = this.normalizePhone(phone);
|
const normalized = normalizeIranMobile(phone);
|
||||||
if (normalized) allowedMobiles.add(normalized);
|
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 {
|
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 {
|
private matchesRoleContext(context: string, role?: string): boolean {
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import {
|
|||||||
} from "src/auth/auth-services/user-auth-error";
|
} from "src/auth/auth-services/user-auth-error";
|
||||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||||
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
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 { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
@@ -17,7 +26,6 @@ export interface LinkBinding {
|
|||||||
linkContext?: string;
|
linkContext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserAuthService {
|
export class UserAuthService {
|
||||||
private readonly logger = new Logger(UserAuthService.name);
|
private readonly logger = new Logger(UserAuthService.name);
|
||||||
@@ -36,18 +44,21 @@ export class UserAuthService {
|
|||||||
pass: string,
|
pass: string,
|
||||||
binding: LinkBinding = {},
|
binding: LinkBinding = {},
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
|
||||||
|
|
||||||
await this.userLinkAccessService.assertMobileAllowed({
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
mobile: username,
|
mobile: canonicalMobile,
|
||||||
linkToken: binding.linkToken,
|
linkToken: binding.linkToken,
|
||||||
linkContext: binding.linkContext,
|
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);
|
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||||
|
|
||||||
const now = new Date().getTime();
|
|
||||||
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
|
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
|
||||||
if (user.otpExpire < now) {
|
if (!isOtpExpiryActive(user.otpExpire)) {
|
||||||
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
||||||
}
|
}
|
||||||
if (await this.hashService.compare(pass, user.otp)) {
|
if (await this.hashService.compare(pass, user.otp)) {
|
||||||
@@ -57,9 +68,10 @@ export class UserAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(user: any) {
|
async login(user: any) {
|
||||||
|
const userId = String(user._id ?? user.id ?? "");
|
||||||
const payload = {
|
const payload = {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
sub: user.id,
|
sub: userId,
|
||||||
role: "user",
|
role: "user",
|
||||||
};
|
};
|
||||||
const accToken = this.jwtService.sign(payload, {
|
const accToken = this.jwtService.sign(payload, {
|
||||||
@@ -70,10 +82,11 @@ export class UserAuthService {
|
|||||||
{
|
{
|
||||||
tokens: { token: accToken },
|
tokens: { token: accToken },
|
||||||
otp: null,
|
otp: null,
|
||||||
|
otpExpire: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
userId: user._id,
|
userId,
|
||||||
access_token: accToken,
|
access_token: accToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,29 +95,31 @@ export class UserAuthService {
|
|||||||
mobile: string,
|
mobile: string,
|
||||||
binding: LinkBinding = {},
|
binding: LinkBinding = {},
|
||||||
): Promise<LoginDtoRs> {
|
): Promise<LoginDtoRs> {
|
||||||
|
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
|
||||||
|
if (!canonicalMobile) {
|
||||||
|
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
await this.userLinkAccessService.assertMobileAllowed({
|
await this.userLinkAccessService.assertMobileAllowed({
|
||||||
mobile,
|
mobile: canonicalMobile,
|
||||||
linkToken: binding.linkToken,
|
linkToken: binding.linkToken,
|
||||||
linkContext: binding.linkContext,
|
linkContext: binding.linkContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userExist = await this.userDbService.findOne({
|
const userExist = await this.userDbService.findOne(
|
||||||
mobile,
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
});
|
);
|
||||||
const otp = this.otpCreator.create();
|
const otp = this.otpCreator.create();
|
||||||
const hashOtp = await this.hashService.hash(otp);
|
const hashOtp = await this.hashService.hash(otp);
|
||||||
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
|
const expireMinutes = readOtpExpireMinutesFromEnv();
|
||||||
const expireMinutes =
|
const nowMs = Date.now();
|
||||||
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
|
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
|
||||||
? rawExpireMinutes
|
|
||||||
: 2;
|
|
||||||
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
|
|
||||||
if (!userExist) {
|
if (!userExist) {
|
||||||
await this.smsSender(otp, mobile);
|
await this.smsSender(otp, canonicalMobile);
|
||||||
/// create otp request
|
|
||||||
const newUser = await this.userDbService.createUser({
|
const newUser = await this.userDbService.createUser({
|
||||||
mobile,
|
mobile: canonicalMobile,
|
||||||
username: mobile,
|
username: canonicalMobile,
|
||||||
otp: hashOtp,
|
otp: hashOtp,
|
||||||
tokens: {
|
tokens: {
|
||||||
token: "",
|
token: "",
|
||||||
@@ -122,22 +137,22 @@ export class UserAuthService {
|
|||||||
});
|
});
|
||||||
return new LoginDtoRs(newUser);
|
return new LoginDtoRs(newUser);
|
||||||
}
|
}
|
||||||
if (userExist) {
|
|
||||||
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) {
|
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
|
||||||
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
||||||
}
|
}
|
||||||
await this.smsSender(otp, mobile);
|
|
||||||
const updateTokens = await this.userDbService.findOneAndUpdate(
|
await this.smsSender(otp, canonicalMobile);
|
||||||
{
|
await this.userDbService.findOneAndUpdate(
|
||||||
username: userExist.username,
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
otp: hashOtp,
|
otp: hashOtp,
|
||||||
otpExpire,
|
otpExpire,
|
||||||
|
mobile: canonicalMobile,
|
||||||
|
username: userExist.username || canonicalMobile,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (updateTokens) return new LoginDtoRs(userExist);
|
return new LoginDtoRs(userExist);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async smsSender(otp: string, mobile: string) {
|
private async smsSender(otp: string, mobile: string) {
|
||||||
|
|||||||
@@ -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 { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.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 {
|
import {
|
||||||
ClaimRequestManagementModel,
|
ClaimRequestManagementModel,
|
||||||
ClaimRequestManagementSchema,
|
ClaimRequestManagementSchema,
|
||||||
@@ -44,6 +48,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
|||||||
name: ClaimRequestManagementModel.name,
|
name: ClaimRequestManagementModel.name,
|
||||||
schema: ClaimRequestManagementSchema,
|
schema: ClaimRequestManagementSchema,
|
||||||
},
|
},
|
||||||
|
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||||
]),
|
]),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
signOptions: { expiresIn: "1h" },
|
signOptions: { expiresIn: "1h" },
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class CaptchaChallengeService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
captchaId,
|
captchaId,
|
||||||
text: generated.text,
|
text: generated.text, /* TODO: REMOVE THIS FOR PROD*/
|
||||||
image: generated.image,
|
image: generated.image,
|
||||||
expiresAt: generated.expiresAt,
|
expiresAt: generated.expiresAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import { UserRatingDto } from "./dto/user-rating.dto";
|
|||||||
import {
|
import {
|
||||||
canFinalizeExpertResend,
|
canFinalizeExpertResend,
|
||||||
documentKeyAllowedForExpertResend,
|
documentKeyAllowedForExpertResend,
|
||||||
|
mapExpertResendCarPartsForClient,
|
||||||
normalizeResendDocumentKeys,
|
normalizeResendDocumentKeys,
|
||||||
normalizeResendPartKeys,
|
normalizeResendPartKeys,
|
||||||
partKeyAllowedForExpertResend,
|
partKeyAllowedForExpertResend,
|
||||||
@@ -4753,7 +4754,11 @@ export class ClaimRequestManagementService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
normalizeResendDocumentKeys(r.resendDocuments).length > 0 ||
|
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(
|
throw new BadRequestException(
|
||||||
"Upload all requested documents and part photos before completing the resend step.",
|
"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 damagedPartsData = claim.media?.damagedParts as any;
|
||||||
const resendPartKeys = normalizeResendPartKeys(
|
const resendPartKeys = normalizeResendPartKeys(
|
||||||
claim.evaluation?.damageExpertResend?.resendCarParts,
|
claim.evaluation?.damageExpertResend?.resendCarParts,
|
||||||
|
carTypeDetails,
|
||||||
|
claim.damage?.selectedParts,
|
||||||
);
|
);
|
||||||
const displayParts = [...selectedNormDetails];
|
const displayParts = [...selectedNormDetails];
|
||||||
const seenDetailKeys = new Set(
|
const seenDetailKeys = new Set(
|
||||||
@@ -6190,7 +6197,12 @@ export class ClaimRequestManagementService {
|
|||||||
? {
|
? {
|
||||||
resendDescription: er.resendDescription,
|
resendDescription: er.resendDescription,
|
||||||
resendDocuments: normalizeResendDocumentKeys(er.resendDocuments),
|
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,
|
fulfilledAt: er.fulfilledAt,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -67,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
|
|||||||
@ApiPropertyOptional({ type: [String] })
|
@ApiPropertyOptional({ type: [String] })
|
||||||
resendDocuments?: string[];
|
resendDocuments?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [Object] })
|
@ApiPropertyOptional({
|
||||||
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
|
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' })
|
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
|
||||||
fulfilledAt?: Date;
|
fulfilledAt?: Date;
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ export class ClaimWorkflow {
|
|||||||
|
|
||||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
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);
|
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import {
|
|||||||
ExpertFileAssignResultDto,
|
ExpertFileAssignResultDto,
|
||||||
ExpertFileAssignStatus,
|
ExpertFileAssignStatus,
|
||||||
} from "src/common/dto/expert-file-assign-result.dto";
|
} 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 { 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -279,6 +285,11 @@ export class ExpertBlameService {
|
|||||||
buckets.all++;
|
buckets.all++;
|
||||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
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;
|
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(
|
const cleared = await this.blameRequestDbService.findOneAndUpdate(
|
||||||
filter as any,
|
filter as any,
|
||||||
{
|
{
|
||||||
$set: { "workflow.locked": false },
|
$set: expireSet,
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
"workflow.expiredAt": "",
|
"workflow.expiredAt": "",
|
||||||
@@ -1051,24 +1070,29 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expertOid = new Types.ObjectId(actor.sub);
|
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 lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
|
||||||
const lockEnforced =
|
const lockEnforced =
|
||||||
request.workflow?.locked &&
|
request.workflow?.locked &&
|
||||||
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
|
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
|
||||||
|
|
||||||
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||||||
throw new ConflictException({
|
throw new ConflictException(
|
||||||
success: false,
|
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lockEnforced && lockedById === actor.sub) {
|
if (lockEnforced && lockedById === actor.sub) {
|
||||||
@@ -1084,17 +1108,21 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
|
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: {
|
$set: {
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
|
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
|
||||||
"workflow.lockedBy": {
|
"workflow.lockedBy": lockedByPayload,
|
||||||
actorId: expertOid,
|
|
||||||
actorName: actor.fullName || "Unknown Expert",
|
|
||||||
actorRole: "expert",
|
|
||||||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
$push: {
|
$push: {
|
||||||
history: {
|
history: {
|
||||||
@@ -1109,17 +1137,31 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
if (!request.workflow?.assignedForReviewBy) {
|
||||||
|
lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload;
|
||||||
|
}
|
||||||
|
|
||||||
const assignFilter: Record<string, unknown> = {
|
const assignFilter: Record<string, unknown> = {
|
||||||
_id: new Types.ObjectId(requestId),
|
_id: new Types.ObjectId(requestId),
|
||||||
status: CaseStatus.WAITING_FOR_EXPERT,
|
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||||
blameStatus: BlameStatus.DISAGREEMENT,
|
blameStatus: BlameStatus.DISAGREEMENT,
|
||||||
|
$and: [
|
||||||
|
{
|
||||||
$or: [
|
$or: [
|
||||||
{ "workflow.locked": { $ne: true } },
|
{ "workflow.locked": { $ne: true } },
|
||||||
{ "workflow.locked": false },
|
{ "workflow.locked": false },
|
||||||
{ "workflow.expiredAt": { $lte: now } },
|
{ "workflow.expiredAt": { $lte: now } },
|
||||||
{ "workflow.lockedBy.actorId": expertOid },
|
{ "workflow.lockedBy.actorId": expertOid },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ "workflow.assignedForReviewBy": { $exists: false } },
|
||||||
|
{ "workflow.assignedForReviewBy": null },
|
||||||
|
{ "workflow.assignedForReviewBy.actorId": expertOid },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.expertInitiated) {
|
if (request.expertInitiated) {
|
||||||
@@ -1135,6 +1177,19 @@ export class ExpertBlameService {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
const latest = await this.blameRequestDbService.findById(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 ?? "");
|
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
|
||||||
if (
|
if (
|
||||||
latest?.workflow?.locked &&
|
latest?.workflow?.locked &&
|
||||||
@@ -1142,18 +1197,9 @@ export class ExpertBlameService {
|
|||||||
otherId &&
|
otherId &&
|
||||||
otherId !== actor.sub
|
otherId !== actor.sub
|
||||||
) {
|
) {
|
||||||
throw new ConflictException({
|
throw new ConflictException(
|
||||||
success: false,
|
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
|
||||||
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 BadRequestException({
|
throw new BadRequestException({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
ExpertFileAssignResultDto,
|
ExpertFileAssignResultDto,
|
||||||
ExpertFileAssignStatus,
|
ExpertFileAssignStatus,
|
||||||
} from "src/common/dto/expert-file-assign-result.dto";
|
} 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 { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||||||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
@@ -108,6 +114,7 @@ import {
|
|||||||
sanitizeDamageSelectedPartV2,
|
sanitizeDamageSelectedPartV2,
|
||||||
type DamageSelectedPartV2,
|
type DamageSelectedPartV2,
|
||||||
} from "src/helpers/outer-damage-parts";
|
} from "src/helpers/outer-damage-parts";
|
||||||
|
import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot";
|
||||||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
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 lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||||||
const lockEnforced =
|
const lockEnforced =
|
||||||
claim.workflow?.locked &&
|
claim.workflow?.locked &&
|
||||||
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||||||
|
|
||||||
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||||||
throw new ConflictException({
|
throw new ConflictException(
|
||||||
success: false,
|
buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow),
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lockEnforced && lockedById === actor.sub) {
|
if (lockEnforced && lockedById === actor.sub) {
|
||||||
@@ -2406,21 +2419,22 @@ export class ExpertClaimService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const expertOid = new Types.ObjectId(actor.sub);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
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> = {
|
const baseLockUpdate: Record<string, unknown> = {
|
||||||
"workflow.locked": true,
|
"workflow.locked": true,
|
||||||
"workflow.lockedAt": now,
|
"workflow.lockedAt": now,
|
||||||
"workflow.expiredAt": expiredAt,
|
"workflow.expiredAt": expiredAt,
|
||||||
"workflow.lockedBy": {
|
"workflow.lockedBy": lockedByPayload,
|
||||||
actorId: expertOid,
|
|
||||||
actorName: actor.fullName,
|
|
||||||
actorRole: "damage_expert",
|
|
||||||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
|
||||||
},
|
|
||||||
"workflow.preLockQueueSnapshot": {
|
"workflow.preLockQueueSnapshot": {
|
||||||
claimStatus: claim.claimStatus,
|
claimStatus: claim.claimStatus,
|
||||||
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||||||
@@ -2455,6 +2469,10 @@ export class ExpertClaimService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!claim.workflow?.assignedForReviewBy) {
|
||||||
|
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
|
||||||
|
}
|
||||||
|
|
||||||
const lockUpdate: Record<string, unknown> = isFactorValidationLock
|
const lockUpdate: Record<string, unknown> = isFactorValidationLock
|
||||||
? baseLockUpdate
|
? baseLockUpdate
|
||||||
: {
|
: {
|
||||||
@@ -2466,12 +2484,23 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const assignFilter: Record<string, unknown> = {
|
const assignFilter: Record<string, unknown> = {
|
||||||
_id: new Types.ObjectId(claimRequestId),
|
_id: new Types.ObjectId(claimRequestId),
|
||||||
|
$and: [
|
||||||
|
{
|
||||||
$or: [
|
$or: [
|
||||||
{ "workflow.locked": { $ne: true } },
|
{ "workflow.locked": { $ne: true } },
|
||||||
{ "workflow.locked": false },
|
{ "workflow.locked": false },
|
||||||
{ "workflow.expiredAt": { $lte: now } },
|
{ "workflow.expiredAt": { $lte: now } },
|
||||||
{ "workflow.lockedBy.actorId": expertOid },
|
{ "workflow.lockedBy.actorId": expertOid },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ "workflow.assignedForReviewBy": { $exists: false } },
|
||||||
|
{ "workflow.assignedForReviewBy": null },
|
||||||
|
{ "workflow.assignedForReviewBy.actorId": expertOid },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isFactorValidationLock) {
|
if (isFactorValidationLock) {
|
||||||
@@ -2497,6 +2526,19 @@ export class ExpertClaimService {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
const latest = await this.claimCaseDbService.findById(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 ?? "");
|
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
|
||||||
if (
|
if (
|
||||||
latest?.workflow?.locked &&
|
latest?.workflow?.locked &&
|
||||||
@@ -2504,18 +2546,9 @@ export class ExpertClaimService {
|
|||||||
otherId &&
|
otherId &&
|
||||||
otherId !== actor.sub
|
otherId !== actor.sub
|
||||||
) {
|
) {
|
||||||
throw new ConflictException({
|
throw new ConflictException(
|
||||||
success: false,
|
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
|
||||||
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 BadRequestException({
|
throw new BadRequestException({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -2680,19 +2713,12 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
const uniqueDocs = [...new Set(docs)];
|
const uniqueDocs = [...new Set(docs)];
|
||||||
|
|
||||||
const normalizedParts = (reply.resendCarParts ?? []).map((p) => {
|
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const row: Record<string, unknown> = {
|
const normalizedParts = normalizeResendCarPartsForStorage(
|
||||||
key: p.key,
|
reply.resendCarParts,
|
||||||
label_fa: p.label_fa,
|
carType,
|
||||||
label_en: p.label_en,
|
claim.damage?.selectedParts,
|
||||||
captured: false,
|
);
|
||||||
};
|
|
||||||
const id = (p as { id?: number }).id;
|
|
||||||
if (typeof id === "number" && Number.isFinite(id)) {
|
|
||||||
row.id = Math.trunc(id);
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
const desc = String(reply.resendDescription ?? "").trim();
|
const desc = String(reply.resendDescription ?? "").trim();
|
||||||
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
|
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
|
||||||
@@ -3195,6 +3221,11 @@ export class ExpertClaimService {
|
|||||||
buckets.all++;
|
buckets.all++;
|
||||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
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;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3652,10 +3683,18 @@ export class ExpertClaimService {
|
|||||||
"workflow.preLockQueueSnapshot": "",
|
"workflow.preLockQueueSnapshot": "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
|
||||||
|
claim.workflow,
|
||||||
|
);
|
||||||
|
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
|
||||||
|
if (assigneeToPersist) {
|
||||||
|
expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist;
|
||||||
|
}
|
||||||
|
|
||||||
const update = needsQueueRestore
|
const update = needsQueueRestore
|
||||||
? {
|
? {
|
||||||
$set: {
|
$set: {
|
||||||
"workflow.locked": false,
|
...expireLockSet,
|
||||||
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||||
claimStatus: restoreClaimStatus,
|
claimStatus: restoreClaimStatus,
|
||||||
"workflow.currentStep": restoreCurrent,
|
"workflow.currentStep": restoreCurrent,
|
||||||
@@ -3664,7 +3703,7 @@ export class ExpertClaimService {
|
|||||||
$unset: lockFieldsUnset,
|
$unset: lockFieldsUnset,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
$set: { "workflow.locked": false },
|
$set: expireLockSet,
|
||||||
$unset: lockFieldsUnset,
|
$unset: lockFieldsUnset,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
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 { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
import { getDamagedPartCaptureBlob } from "./claim-car-angle-media";
|
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 { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
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: {
|
export function resendRequestHasPayload(r: {
|
||||||
resendDescription?: string;
|
resendDescription?: string;
|
||||||
resendDocuments?: unknown[];
|
resendDocuments?: unknown[];
|
||||||
@@ -34,18 +54,161 @@ export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string
|
|||||||
return [...new Set(keys)];
|
return [...new Set(keys)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
|
/** Capture / finalize lookup keys for expert-requested damaged parts. */
|
||||||
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
export function normalizeResendPartKeys(
|
||||||
|
parts: unknown[] | undefined,
|
||||||
|
carType?: ClaimVehicleTypeV2,
|
||||||
|
selectedParts?: unknown,
|
||||||
|
): string[] {
|
||||||
if (!Array.isArray(parts)) return [];
|
if (!Array.isArray(parts)) return [];
|
||||||
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
|
selectedParts,
|
||||||
|
carType,
|
||||||
|
);
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const p of parts) {
|
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());
|
if (typeof p === "string" && p.trim()) keys.push(p.trim());
|
||||||
else if (p && typeof p === "object" && "key" in (p as object)) {
|
else if (p && typeof p === "object" && "key" in (p as object)) {
|
||||||
const k = String((p as { key?: string }).key || "").trim();
|
const k = String((p as { key?: string }).key || "").trim();
|
||||||
if (k) keys.push(k);
|
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 {
|
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 {
|
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
|
||||||
const data = claim?.media?.damagedParts;
|
const data = claim?.media?.damagedParts;
|
||||||
if (!data) return false;
|
if (!data) return false;
|
||||||
|
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const norm = normalizeDamageSelectedParts(
|
const norm = normalizeDamageSelectedParts(
|
||||||
claim?.damage?.selectedParts,
|
claim?.damage?.selectedParts,
|
||||||
claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined,
|
carType,
|
||||||
claim?.damage?.selectedOuterParts,
|
claim?.damage?.selectedOuterParts,
|
||||||
);
|
);
|
||||||
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
|
return getDamagedPartCaptureBlob(data, partKey, norm) != null;
|
||||||
@@ -82,8 +246,13 @@ export function canFinalizeExpertResend(claim: any): boolean {
|
|||||||
const r = claim.evaluation?.damageExpertResend;
|
const r = claim.evaluation?.damageExpertResend;
|
||||||
if (!r || r.fulfilledAt) return false;
|
if (!r || r.fulfilledAt) return false;
|
||||||
|
|
||||||
|
const carType = claim?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
|
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) {
|
if (docKeys.length === 0 && partKeys.length === 0) {
|
||||||
return String(r.resendDescription || "").trim() !== "";
|
return String(r.resendDescription || "").trim() !== "";
|
||||||
@@ -113,6 +282,13 @@ export function documentKeyAllowedForExpertResend(
|
|||||||
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||||
const r = claim?.evaluation?.damageExpertResend;
|
const r = claim?.evaluation?.damageExpertResend;
|
||||||
if (!r || r.fulfilledAt) return false;
|
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);
|
return allowed.has(partKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
|
|||||||
|
|
||||||
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
|
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
|
||||||
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
|
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
|
||||||
CaseStatus.OPEN,
|
// CaseStatus.OPEN,
|
||||||
CaseStatus.WAITING_FOR_SECOND_PARTY,
|
// CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
77
src/helpers/expert-workflow-review-assignee.ts
Normal file
77
src/helpers/expert-workflow-review-assignee.ts
Normal 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);
|
||||||
|
}
|
||||||
46
src/helpers/iran-mobile.ts
Normal file
46
src/helpers/iran-mobile.ts
Normal 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 }]),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -301,6 +301,19 @@ export function normalizeDamageSelectedParts(
|
|||||||
}
|
}
|
||||||
if (typeof el === "object") {
|
if (typeof el === "object") {
|
||||||
const o = el as Record<string, unknown>;
|
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()) {
|
if (typeof o.name === "string" && o.name.trim()) {
|
||||||
let name = o.name.trim();
|
let name = o.name.trim();
|
||||||
const inner = splitCatalogKeyToNameAndSide(name);
|
const inner = splitCatalogKeyToNameAndSide(name);
|
||||||
|
|||||||
43
src/helpers/user-otp-expiry.ts
Normal file
43
src/helpers/user-otp-expiry.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -48,5 +48,12 @@ export class Workflow {
|
|||||||
|
|
||||||
@Prop({ type: ActorLockSchema })
|
@Prop({ type: ActorLockSchema })
|
||||||
lockedBy?: ActorLock;
|
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);
|
export const WorkflowSchema = SchemaFactory.createForClass(Workflow);
|
||||||
Reference in New Issue
Block a user