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/captcha/captcha-challenge.service.ts b/src/captcha/captcha-challenge.service.ts index 3a6c6be..0145635 100644 --- a/src/captcha/captcha-challenge.service.ts +++ b/src/captcha/captcha-challenge.service.ts @@ -35,7 +35,7 @@ export class CaptchaChallengeService { return { captchaId, - text: generated.text, + text: generated.text, /* TODO: REMOVE THIS FOR PROD*/ image: generated.image, expiresAt: generated.expiresAt, }; 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/claim-request-management/entites/schema/claim-case.workflow.schema.ts b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts index fc16851..f3169d6 100644 --- a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts @@ -66,6 +66,13 @@ export class ClaimWorkflow { @Prop({ type: ClaimPreLockQueueSnapshotSchema }) preLockQueueSnapshot?: ClaimPreLockQueueSnapshot; + + /** + * First damage expert who called review assign; kept after the 15m lock expires + * so no other expert can take the case while it remains in the expert queue. + */ + @Prop({ type: ClaimActorLockSchema }) + assignedForReviewBy?: ClaimActorLock; } export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow); diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index ad27fbd..fa96f6c 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -28,6 +28,12 @@ import { ExpertFileAssignResultDto, ExpertFileAssignStatus, } from "src/common/dto/expert-file-assign-result.dto"; +import { + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + blameWorkflowAssigneeToPersistOnLockExpiry, + buildExpertAssignConflictForOtherReviewer, + resolvePersistentReviewAssigneeId, +} from "src/helpers/expert-workflow-review-assignee"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -279,6 +285,11 @@ export class ExpertBlameService { buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } + + // Adding extra fields for front-end + buckets["PENDING_ON_USER"] = buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] + buckets[CaseStatus.WAITING_FOR_SIGNATURES]; + buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT]; + return buckets; } @@ -536,10 +547,18 @@ export class ExpertBlameService { ); } + const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry( + request.workflow, + ); + const expireSet: Record = { "workflow.locked": false }; + if (assigneeToPersist) { + expireSet["workflow.assignedForReviewBy"] = assigneeToPersist; + } + const cleared = await this.blameRequestDbService.findOneAndUpdate( filter as any, { - $set: { "workflow.locked": false }, + $set: expireSet, $unset: { "workflow.lockedAt": "", "workflow.expiredAt": "", @@ -1051,24 +1070,29 @@ export class ExpertBlameService { } const expertOid = new Types.ObjectId(actor.sub); + const reviewAssigneeId = resolvePersistentReviewAssigneeId( + request.workflow, + request.history, + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (reviewAssigneeId && reviewAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + reviewAssigneeId, + request.workflow, + ), + ); + } + const lockedById = String(request.workflow?.lockedBy?.actorId ?? ""); const lockEnforced = request.workflow?.locked && this.isBlameV2WorkflowLockCurrentlyEnforced(request); if (lockEnforced && lockedById && lockedById !== actor.sub) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: lockedById, - actorName: request.workflow?.lockedBy?.actorName, - lockedAt: request.workflow?.lockedAt - ? new Date(request.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow), + ); } if (lockEnforced && lockedById === actor.sub) { @@ -1084,17 +1108,21 @@ export class ExpertBlameService { const now = new Date(); const lockSnapshot = await this.snapshotFieldExpert(actor.sub); - const lockUpdate = { + const lockedByPayload = { + actorId: expertOid, + actorName: actor.fullName || "Unknown Expert", + actorRole: "expert" as const, + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), + }; + const lockUpdate: { + $set: Record; + $push: Record; + } = { $set: { "workflow.locked": true, "workflow.lockedAt": now, "workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs), - "workflow.lockedBy": { - actorId: expertOid, - actorName: actor.fullName || "Unknown Expert", - actorRole: "expert", - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, + "workflow.lockedBy": lockedByPayload, }, $push: { history: { @@ -1109,16 +1137,30 @@ export class ExpertBlameService { }, }, }; + if (!request.workflow?.assignedForReviewBy) { + lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload; + } const assignFilter: Record = { _id: new Types.ObjectId(requestId), status: CaseStatus.WAITING_FOR_EXPERT, blameStatus: BlameStatus.DISAGREEMENT, - $or: [ - { "workflow.locked": { $ne: true } }, - { "workflow.locked": false }, - { "workflow.expiredAt": { $lte: now } }, - { "workflow.lockedBy.actorId": expertOid }, + $and: [ + { + $or: [ + { "workflow.locked": { $ne: true } }, + { "workflow.locked": false }, + { "workflow.expiredAt": { $lte: now } }, + { "workflow.lockedBy.actorId": expertOid }, + ], + }, + { + $or: [ + { "workflow.assignedForReviewBy": { $exists: false } }, + { "workflow.assignedForReviewBy": null }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + ], + }, ], }; @@ -1135,6 +1177,19 @@ export class ExpertBlameService { if (!updated) { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); const latest = await this.blameRequestDbService.findById(requestId); + const otherAssigneeId = resolvePersistentReviewAssigneeId( + latest?.workflow, + latest?.history, + BLAME_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (otherAssigneeId && otherAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + otherAssigneeId, + latest?.workflow, + ), + ); + } const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); if ( latest?.workflow?.locked && @@ -1142,18 +1197,9 @@ export class ExpertBlameService { otherId && otherId !== actor.sub ) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: otherId, - actorName: latest.workflow?.lockedBy?.actorName, - lockedAt: latest.workflow?.lockedAt - ? new Date(latest.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow), + ); } throw new BadRequestException({ success: false, diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index be10a07..c2d7917 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -35,6 +35,12 @@ import { ExpertFileAssignResultDto, ExpertFileAssignStatus, } from "src/common/dto/expert-file-assign-result.dto"; +import { + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + claimWorkflowAssigneeToPersistOnLockExpiry, + buildExpertAssignConflictForOtherReviewer, + resolvePersistentReviewAssigneeId, +} from "src/helpers/expert-workflow-review-assignee"; import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto"; import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; @@ -108,6 +114,7 @@ import { sanitizeDamageSelectedPartV2, type DamageSelectedPartV2, } from "src/helpers/outer-damage-parts"; +import { normalizeResendCarPartsForStorage } from "src/helpers/claim-expert-resend"; import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; import { snapshotFromDamageExpert } from "src/helpers/expert-profile-snapshot"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; @@ -2375,24 +2382,30 @@ export class ExpertClaimService { }); } + const expertOid = new Types.ObjectId(actor.sub); + const reviewAssigneeId = resolvePersistentReviewAssigneeId( + claim.workflow, + claim.history, + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (reviewAssigneeId && reviewAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + reviewAssigneeId, + claim.workflow, + ), + ); + } + const lockedById = String(claim.workflow?.lockedBy?.actorId ?? ""); const lockEnforced = claim.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(claim); if (lockEnforced && lockedById && lockedById !== actor.sub) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: lockedById, - actorName: claim.workflow?.lockedBy?.actorName, - lockedAt: claim.workflow?.lockedAt - ? new Date(claim.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow), + ); } if (lockEnforced && lockedById === actor.sub) { @@ -2406,21 +2419,22 @@ export class ExpertClaimService { }; } - const expertOid = new Types.ObjectId(actor.sub); const now = new Date(); const lockSnapshot = await this.snapshotDamageExpert(actor.sub); const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs); + const lockedByPayload = { + actorId: expertOid, + actorName: actor.fullName, + actorRole: "damage_expert" as const, + ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), + }; + const baseLockUpdate: Record = { "workflow.locked": true, "workflow.lockedAt": now, "workflow.expiredAt": expiredAt, - "workflow.lockedBy": { - actorId: expertOid, - actorName: actor.fullName, - actorRole: "damage_expert", - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, + "workflow.lockedBy": lockedByPayload, "workflow.preLockQueueSnapshot": { claimStatus: claim.claimStatus, currentStep: this.normalizeClaimWorkflowStepForSnapshot( @@ -2455,6 +2469,10 @@ export class ExpertClaimService { }, }; + if (!claim.workflow?.assignedForReviewBy) { + baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload; + } + const lockUpdate: Record = isFactorValidationLock ? baseLockUpdate : { @@ -2466,11 +2484,22 @@ export class ExpertClaimService { const assignFilter: Record = { _id: new Types.ObjectId(claimRequestId), - $or: [ - { "workflow.locked": { $ne: true } }, - { "workflow.locked": false }, - { "workflow.expiredAt": { $lte: now } }, - { "workflow.lockedBy.actorId": expertOid }, + $and: [ + { + $or: [ + { "workflow.locked": { $ne: true } }, + { "workflow.locked": false }, + { "workflow.expiredAt": { $lte: now } }, + { "workflow.lockedBy.actorId": expertOid }, + ], + }, + { + $or: [ + { "workflow.assignedForReviewBy": { $exists: false } }, + { "workflow.assignedForReviewBy": null }, + { "workflow.assignedForReviewBy.actorId": expertOid }, + ], + }, ], }; @@ -2497,6 +2526,19 @@ export class ExpertClaimService { if (!updated) { await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const latest = await this.claimCaseDbService.findById(claimRequestId); + const otherAssigneeId = resolvePersistentReviewAssigneeId( + latest?.workflow, + latest?.history, + CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE, + ); + if (otherAssigneeId && otherAssigneeId !== actor.sub) { + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer( + otherAssigneeId, + latest?.workflow, + ), + ); + } const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); if ( latest?.workflow?.locked && @@ -2504,18 +2546,9 @@ export class ExpertClaimService { otherId && otherId !== actor.sub ) { - throw new ConflictException({ - success: false, - status: "locked" satisfies ExpertFileAssignStatus, - message: "Request is already being processed by another expert", - lockedBy: { - actorId: otherId, - actorName: latest.workflow?.lockedBy?.actorName, - lockedAt: latest.workflow?.lockedAt - ? new Date(latest.workflow.lockedAt as Date).toISOString() - : undefined, - }, - }); + throw new ConflictException( + buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow), + ); } throw new BadRequestException({ success: false, @@ -2680,19 +2713,12 @@ export class ExpertClaimService { } const uniqueDocs = [...new Set(docs)]; - const normalizedParts = (reply.resendCarParts ?? []).map((p) => { - const row: Record = { - key: p.key, - label_fa: p.label_fa, - label_en: p.label_en, - captured: false, - }; - const id = (p as { id?: number }).id; - if (typeof id === "number" && Number.isFinite(id)) { - row.id = Math.trunc(id); - } - return row; - }); + const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined; + const normalizedParts = normalizeResendCarPartsForStorage( + reply.resendCarParts, + carType, + claim.damage?.selectedParts, + ); const desc = String(reply.resendDescription ?? "").trim(); if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) { @@ -3195,6 +3221,11 @@ export class ExpertClaimService { buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } + + // Add new calculated fields for front-end + buckets["PENDING_ON_USER"] = buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] + buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] + buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] + buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] + buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING]; + buckets["CHECK_NEEDED"] = buckets["PENDING_ON_USER"] + buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] + return buckets; } @@ -3652,10 +3683,18 @@ export class ExpertClaimService { "workflow.preLockQueueSnapshot": "", }; + const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry( + claim.workflow, + ); + const expireLockSet: Record = { "workflow.locked": false }; + if (assigneeToPersist) { + expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist; + } + const update = needsQueueRestore ? { $set: { - "workflow.locked": false, + ...expireLockSet, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, claimStatus: restoreClaimStatus, "workflow.currentStep": restoreCurrent, @@ -3664,7 +3703,7 @@ export class ExpertClaimService { $unset: lockFieldsUnset, } : { - $set: { "workflow.locked": false }, + $set: expireLockSet, $unset: lockFieldsUnset, }; 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/expert-panel-status-report.ts b/src/helpers/expert-panel-status-report.ts index aa13797..f8b5e63 100644 --- a/src/helpers/expert-panel-status-report.ts +++ b/src/helpers/expert-panel-status-report.ts @@ -3,8 +3,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim- /** Blame: early workflow before expert queue / resend / signatures / terminals. */ const BLAME_IN_PROGRESS_STATUSES = new Set([ - CaseStatus.OPEN, - CaseStatus.WAITING_FOR_SECOND_PARTY, + // CaseStatus.OPEN, + // CaseStatus.WAITING_FOR_SECOND_PARTY, ]); /** diff --git a/src/helpers/expert-workflow-review-assignee.ts b/src/helpers/expert-workflow-review-assignee.ts new file mode 100644 index 0000000..81219b5 --- /dev/null +++ b/src/helpers/expert-workflow-review-assignee.ts @@ -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); +} 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; +} diff --git a/src/request-management/entities/schema/workflow.type.ts b/src/request-management/entities/schema/workflow.type.ts index a2d30bb..e1cd915 100644 --- a/src/request-management/entities/schema/workflow.type.ts +++ b/src/request-management/entities/schema/workflow.type.ts @@ -48,5 +48,12 @@ export class Workflow { @Prop({ type: ActorLockSchema }) lockedBy?: ActorLock; + + /** + * First expert who called review assign; kept after the 15m workflow lock expires + * so no other expert can take the case while it remains in the expert queue. + */ + @Prop({ type: ActorLockSchema }) + assignedForReviewBy?: ActorLock; } export const WorkflowSchema = SchemaFactory.createForClass(Workflow); \ No newline at end of file