diff --git a/src/auth/auth-services/user-link-access.service.ts b/src/auth/auth-services/user-link-access.service.ts index 731441e..92d2168 100644 --- a/src/auth/auth-services/user-link-access.service.ts +++ b/src/auth/auth-services/user-link-access.service.ts @@ -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, @InjectModel(ClaimRequestManagementModel.name) private readonly claimRequestManagementModel: Model, + @InjectModel(ClaimCase.name) + private readonly claimCaseModel: Model, 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(); - 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, 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, + 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, 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 { diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 035de71..ae28ee0 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -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 { + 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 { + 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) { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 498a66a..0fe9cb0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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" }, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 13678f1..0b1d283 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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; diff --git a/src/claim-request-management/dto/claim-details-v2.dto.ts b/src/claim-request-management/dto/claim-details-v2.dto.ts index 1017d6d..535e456 100644 --- a/src/claim-request-management/dto/claim-details-v2.dto.ts +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -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; diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index aa53f97..c2d7917 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -114,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"; @@ -2712,19 +2713,12 @@ export class ExpertClaimService { } const uniqueDocs = [...new Set(docs)]; - const normalizedParts = (reply.resendCarParts ?? []).map((p) => { - const row: Record = { - 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) { diff --git a/src/helpers/claim-expert-resend.ts b/src/helpers/claim-expert-resend.ts index 0bbe23a..3b50371 100644 --- a/src/helpers/claim-expert-resend.ts +++ b/src/helpers/claim-expert-resend.ts @@ -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; + 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) : {}; + 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> { + if (!Array.isArray(rawParts)) return []; + const selectedNorm = normalizeDamageSelectedParts(selectedParts, carType); + return rawParts.map((raw) => { + const stored = + raw && typeof raw === "object" ? (raw as Record) : {}; + const sp = resolveResendPartRow(raw, carType, selectedNorm); + const catalogKey = + sp.catalogKey?.trim() || catalogLikeKeyFromPart(sp); + const row: Record = { + 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); } diff --git a/src/helpers/iran-mobile.ts b/src/helpers/iran-mobile.ts new file mode 100644 index 0000000..4e9e5da --- /dev/null +++ b/src/helpers/iran-mobile.ts @@ -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(); + 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 { + const variants = iranMobileLookupVariants(phone); + if (variants.length === 0) { + return { username: phone }; + } + return { + $or: variants.flatMap((v) => [{ username: v }, { mobile: v }]), + }; +} diff --git a/src/helpers/outer-damage-parts.ts b/src/helpers/outer-damage-parts.ts index f1e8e5c..2cf577d 100644 --- a/src/helpers/outer-damage-parts.ts +++ b/src/helpers/outer-damage-parts.ts @@ -301,6 +301,19 @@ export function normalizeDamageSelectedParts( } if (typeof el === "object") { const o = el as Record; + 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); diff --git a/src/helpers/user-otp-expiry.ts b/src/helpers/user-otp-expiry.ts new file mode 100644 index 0000000..43c5f03 --- /dev/null +++ b/src/helpers/user-otp-expiry.ts @@ -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; +}