From 2bdd0d507e9397cc9f26d46d79ace5930a605786 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sun, 24 May 2026 13:34:43 +0330 Subject: [PATCH] YARA-951 --- src/auth/dto/captcha-response.dto.ts | 6 + src/captcha/captcha-challenge.service.ts | 1 + .../entites/schema/claim-cases.schema.ts | 2 +- .../dto/expert-file-assign-result.dto.ts | 38 ++ src/expert-blame/expert-blame.service.ts | 315 ++++++++++----- .../expert-blame.v2.controller.ts | 33 +- src/expert-claim/expert-claim.service.ts | 374 ++++++++++++------ .../expert-claim.v2.controller.ts | 28 +- .../entities/schema/blame-cases.schema.ts | 3 +- 9 files changed, 566 insertions(+), 234 deletions(-) create mode 100644 src/common/dto/expert-file-assign-result.dto.ts diff --git a/src/auth/dto/captcha-response.dto.ts b/src/auth/dto/captcha-response.dto.ts index 6e4bfa3..aca3919 100644 --- a/src/auth/dto/captcha-response.dto.ts +++ b/src/auth/dto/captcha-response.dto.ts @@ -7,6 +7,12 @@ export class CaptchaResponseDto { }) captchaId: string; + @ApiProperty({ + description: "Sample text", + example: "sb20xe" + }) + text: string + @ApiProperty({ description: "SVG captcha as a data URI — use as `` in the frontend.", diff --git a/src/captcha/captcha-challenge.service.ts b/src/captcha/captcha-challenge.service.ts index 4bfd68f..3a6c6be 100644 --- a/src/captcha/captcha-challenge.service.ts +++ b/src/captcha/captcha-challenge.service.ts @@ -35,6 +35,7 @@ export class CaptchaChallengeService { return { captchaId, + text: generated.text, image: generated.image, expiresAt: generated.expiresAt, }; diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 2df3ae6..7d7f802 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -159,5 +159,5 @@ export class ClaimCase { export type ClaimCaseDocument = HydratedDocument; export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); - +ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 }); diff --git a/src/common/dto/expert-file-assign-result.dto.ts b/src/common/dto/expert-file-assign-result.dto.ts new file mode 100644 index 0000000..3ead305 --- /dev/null +++ b/src/common/dto/expert-file-assign-result.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export type ExpertFileAssignStatus = + | "assigned" + | "already_assigned_to_you" + | "locked" + | "unavailable"; + +export class ExpertFileAssignResultDto { + @ApiProperty({ example: true }) + success: boolean; + + @ApiProperty({ + enum: ["assigned", "already_assigned_to_you", "locked", "unavailable"], + example: "assigned", + }) + status: ExpertFileAssignStatus; + + @ApiPropertyOptional({ + example: "You already started processing this request", + }) + message?: string; + + @ApiPropertyOptional({ + description: "When the expert was assigned (ISO 8601).", + example: "2026-05-24T12:00:00.000Z", + }) + assignedAt?: string; + + @ApiPropertyOptional({ + description: "Present when status is locked (another expert).", + }) + lockedBy?: { + actorId: string; + actorName?: string; + lockedAt?: string; + }; +} diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 765cb92..ad27fbd 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, HttpException, Injectable, @@ -23,6 +24,10 @@ import { } from "src/helpers/expert-portfolio"; import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { + ExpertFileAssignResultDto, + ExpertFileAssignStatus, +} from "src/common/dto/expert-file-assign-result.dto"; 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 { @@ -1000,118 +1005,222 @@ export class ExpertBlameService { } /** - * V2: Lock blame case for 15 minutes (blameCases collection). - * Sets workflow.locked, workflow.lockedBy, workflow.lockedAt. - * Checks if existing lock is expired and allows re-locking. + * Assign the current field expert to a blame case for review (V2). + * Free cases are locked atomically; already-assigned-to-self is idempotent. */ - async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { - try { - const now = new Date(); + async assignBlameCaseForReviewV2( + requestId: string, + actor: { sub: string; fullName?: string; clientKey?: string }, + ): Promise { + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); - await this.expireBlameCaseWorkflowLockV2IfStale(requestId); - // First, check the current state - const request = await this.blameRequestDbService.findById(requestId); - if (!request) { - throw new NotFoundException("Request not found"); - } + const request = await this.blameRequestDbService.findById(requestId); + if (!request) { + throw new NotFoundException("Request not found"); + } - assertBlameCaseForExpertTenant(request, actorDetail); + assertBlameCaseForExpertTenant(request, actor); - // Validate request is available for expert review - if ( - request.status !== CaseStatus.WAITING_FOR_EXPERT || - request.blameStatus !== BlameStatus.DISAGREEMENT - ) { - throw new BadRequestException( - "Request is not available for expert review", - ); - } - - // Expert-initiated: only the initiating field expert can lock and review - if ( - request.expertInitiated && - request.initiatedByFieldExpertId && - String(request.initiatedByFieldExpertId) !== actorDetail.sub - ) { - throw new ForbiddenException( - "Only the field expert who created this file can lock and review it.", - ); - } - - // Check if locked and not expired (`expiredAt` or lockedAt + TTL) - if ( - request.workflow?.locked && - this.isBlameV2WorkflowLockCurrentlyEnforced(request) - ) { - const lockedByActorId = String( - request.workflow.lockedBy?.actorId ?? "", - ); - if (lockedByActorId === actorDetail.sub) { - throw new BadRequestException( - "You have already locked this request", - ); - } - throw new BadRequestException( - "Request is currently locked by another expert", - ); - } - - const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); - - // Lock the request (either unlocked or expired lock) - const updateResult = await this.blameRequestDbService.findByIdAndUpdate( - requestId, - { - $set: { - "workflow.locked": true, - "workflow.lockedAt": now, - "workflow.expiredAt": new Date( - now.getTime() + this.blameV2LockTtlMs, - ), - "workflow.lockedBy": { - actorId: new Types.ObjectId(actorDetail.sub), - actorName: actorDetail.fullName || "Unknown Expert", - actorRole: "expert", - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, - }, - }, - ); - - if (!updateResult) { - throw new InternalServerErrorException("Failed to lock the request"); - } - - await this.recordBlameExpertActivity({ - expertId: String(actorDetail.sub), - requestId: String(requestId), - eventType: ExpertFileActivityType.CHECKED, - tenantId: - String(request.parties?.[0]?.person?.clientId ?? "") || - String(request.parties?.[1]?.person?.clientId ?? ""), - idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`, + if (request.type === BlameRequestType.CAR_BODY) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "CAR_BODY cases do not require expert review", }); + } - if (request.type === BlameRequestType.THIRD_PARTY) { - const phones = (request.parties || []) - .map((p: any) => p?.person?.phoneNumber) - .filter((p: unknown): p is string => typeof p === "string" && p.length > 0); - const expertLastName = - actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; - for (const phone of phones) { - await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( - { - receptor: phone, - fileKind: "blame", - publicId: request.publicId, - expertLastName, - }, - ); - } + if ( + request.status !== CaseStatus.WAITING_FOR_EXPERT || + request.blameStatus !== BlameStatus.DISAGREEMENT + ) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request is not available for expert review", + }); + } + + if ( + request.expertInitiated && + request.initiatedByFieldExpertId && + String(request.initiatedByFieldExpertId) !== actor.sub + ) { + throw new ForbiddenException( + "Only the field expert who created this file can review it", + ); + } + + const expertOid = new Types.ObjectId(actor.sub); + 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, + }, + }); + } + + if (lockEnforced && lockedById === actor.sub) { + return { + success: true, + status: "already_assigned_to_you", + message: "You already started processing this request", + assignedAt: request.workflow?.lockedAt + ? new Date(request.workflow.lockedAt as Date).toISOString() + : undefined, + }; + } + + const now = new Date(); + const lockSnapshot = await this.snapshotFieldExpert(actor.sub); + const lockUpdate = { + $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 }), + }, + }, + $push: { + history: { + type: "BLAME_ASSIGNED", + actor: { + actorId: expertOid, + actorName: actor.fullName || "Unknown Expert", + actorType: "field_expert", + }, + timestamp: now, + metadata: { note: "Expert assigned via review assign endpoint" }, + }, + }, + }; + + 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 }, + ], + }; + + if (request.expertInitiated) { + assignFilter.initiatedByFieldExpertId = expertOid; + } + + const updated = await this.blameRequestDbService.findOneAndUpdate( + assignFilter as any, + lockUpdate, + { new: true }, + ); + + if (!updated) { + await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + const latest = await this.blameRequestDbService.findById(requestId); + const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); + if ( + latest?.workflow?.locked && + this.isBlameV2WorkflowLockCurrentlyEnforced(latest) && + 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 BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request could not be assigned", + }); + } - return { _id: requestId, lock: true }; + await this.recordBlameExpertActivity({ + expertId: String(actor.sub), + requestId: String(requestId), + eventType: ExpertFileActivityType.CHECKED, + tenantId: + String(updated.parties?.[0]?.person?.clientId ?? "") || + String(updated.parties?.[1]?.person?.clientId ?? ""), + idempotencyKey: `blame:${requestId}:checked:${actor.sub}`, + }); + + if (updated.type === BlameRequestType.THIRD_PARTY) { + const phones = (updated.parties || []) + .map((p: any) => p?.person?.phoneNumber) + .filter((p: unknown): p is string => typeof p === "string" && p.length > 0); + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + for (const phone of phones) { + await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( + { + receptor: phone, + fileKind: "blame", + publicId: updated.publicId, + expertLastName, + }, + ); + } + } + + this.logger.log( + `Blame case ${requestId} assigned to expert ${actor.sub} at ${now.toISOString()}`, + ); + + return { + success: true, + status: "assigned", + assignedAt: now.toISOString(), + }; + } + + /** + * V2: Lock blame case for 15 minutes (blameCases collection). + * Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients. + */ + async lockRequestV2( + requestId: string, + actorDetail: any, + ): Promise<{ _id: string; lock: boolean; message?: string }> { + try { + const result = await this.assignBlameCaseForReviewV2(requestId, actorDetail); + return { + _id: requestId, + lock: true, + message: result.message, + }; } catch (error) { + if (error instanceof ConflictException) { + const body = error.getResponse(); + throw new BadRequestException(body); + } if (error instanceof HttpException) throw error; this.logger.error( "lockRequestV2 failed", diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index 58a7191..c1f111f 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -5,11 +5,13 @@ import { HttpException, InternalServerErrorException, Param, + Post, Put, Query, UseGuards, } from "@nestjs/common"; -import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; @@ -79,7 +81,36 @@ export class ExpertBlameV2Controller { } } + @Post(":id/assign") + @ApiOperation({ + summary: "Check availability and assign blame case to this expert", + description: + "Call before opening a case from the list. Returns `assigned` when the case was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Does not replace PUT lock — use this for list UI availability.", + }) + @ApiParam({ name: "id", description: "Blame case request id" }) + @ApiResponse({ status: 200, type: ExpertFileAssignResultDto }) + @ApiResponse({ + status: 409, + description: "Another expert is processing this case", + type: ExpertFileAssignResultDto, + }) + async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) { + try { + return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor); + } catch (error) { + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException( + error instanceof Error ? error.message : "Failed to assign blame case", + ); + } + } + @Put("lock/:id") + @ApiOperation({ + summary: "Lock blame case for review (legacy)", + description: + "Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.", + }) @ApiParam({ name: "id", description: "Blame case request id" }) async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) { try { diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index dc08863..be10a07 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -31,6 +31,10 @@ import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.e import { UserType } from "src/Types&Enums/userType.enum"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; +import { + ExpertFileAssignResultDto, + ExpertFileAssignStatus, +} from "src/common/dto/expert-file-assign-result.dto"; 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"; @@ -2341,6 +2345,218 @@ export class ExpertClaimService { throw new HttpException(error.response, error.claimStatus); } + /** + * Assign the current damage expert to a claim for review (V2). + * Free cases are locked atomically; already-assigned-to-self is idempotent. + */ + async assignClaimForReviewV2( + claimRequestId: string, + actor: { sub: string; fullName?: string; clientKey?: string }, + ): Promise { + requireActorClientKey(actor); + await this.expireClaimWorkflowLockV2IfStale(claimRequestId); + + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (!claim) { + throw new NotFoundException("Claim request not found"); + } + + assertClaimCaseForTenant(claim, actor); + + const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); + const isDamageReviewLock = + claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + + if (!isDamageReviewLock && !isFactorValidationLock) { + throw new BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request is not available for expert review", + }); + } + + 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, + }, + }); + } + + if (lockEnforced && lockedById === actor.sub) { + return { + success: true, + status: "already_assigned_to_you", + message: "You already started processing this request", + assignedAt: claim.workflow?.lockedAt + ? new Date(claim.workflow.lockedAt as Date).toISOString() + : undefined, + }; + } + + 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 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.preLockQueueSnapshot": { + claimStatus: claim.claimStatus, + currentStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow?.currentStep, + ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, + ), + ...(claim.workflow?.nextStep != null + ? { + nextStep: this.normalizeClaimWorkflowStepForSnapshot( + claim.workflow.nextStep, + ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + ), + } + : {}), + }, + $push: { + history: { + type: "CLAIM_ASSIGNED", + actor: { + actorId: expertOid, + actorName: actor.fullName, + actorType: "damage_expert", + }, + timestamp: now, + metadata: { + note: isFactorValidationLock + ? "Expert assigned for factor validation review" + : "Expert assigned via review assign endpoint", + ...(isFactorValidationLock ? { phase: "factorValidation" } : {}), + }, + }, + }, + }; + + const lockUpdate: Record = isFactorValidationLock + ? baseLockUpdate + : { + ...baseLockUpdate, + status: ClaimCaseStatus.EXPERT_REVIEWING, + claimStatus: ClaimStatus.UNDER_REVIEW, + "workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, + }; + + const assignFilter: Record = { + _id: new Types.ObjectId(claimRequestId), + $or: [ + { "workflow.locked": { $ne: true } }, + { "workflow.locked": false }, + { "workflow.expiredAt": { $lte: now } }, + { "workflow.lockedBy.actorId": expertOid }, + ], + }; + + if (isFactorValidationLock) { + assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW; + assignFilter["workflow.currentStep"] = + ClaimWorkflowStep.EXPERT_COST_EVALUATION; + assignFilter.status = { + $in: [ + ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS, + ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL, + ], + }; + } else { + assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; + } + + const updated = await this.claimCaseDbService.findOneAndUpdate( + assignFilter as any, + lockUpdate, + { new: true }, + ); + + if (!updated) { + await this.expireClaimWorkflowLockV2IfStale(claimRequestId); + const latest = await this.claimCaseDbService.findById(claimRequestId); + const otherId = String(latest?.workflow?.lockedBy?.actorId ?? ""); + if ( + latest?.workflow?.locked && + this.isClaimV2WorkflowLockCurrentlyEnforced(latest) && + 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 BadRequestException({ + success: false, + status: "unavailable" satisfies ExpertFileAssignStatus, + message: "Request could not be assigned", + }); + } + + await this.recordClaimExpertActivity({ + expertId: String(actor.sub), + tenantId: this.claimActivityTenantId(updated, actor), + claimId: String(claimRequestId), + eventType: ExpertFileActivityType.CHECKED, + idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`, + }); + + const ownerPhone = await this.resolveClaimOwnerPhone(updated); + if (ownerPhone) { + const expertLastName = + actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; + await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( + { + receptor: ownerPhone, + fileKind: "claim", + publicId: updated.publicId, + expertLastName, + }, + ); + } + + this.logger.log( + `Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`, + ); + + return { + success: true, + status: "assigned", + assignedAt: now.toISOString(), + }; + } + /** * V2: Lock a claim request for expert review. * @@ -2362,136 +2578,42 @@ export class ExpertClaimService { * `workflow.currentStep` untouched so the claim stays in the factor-validation * queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep * treating it correctly when the lock expires). + * + * Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients. */ async lockClaimRequestV2(claimRequestId: string, actor: any) { - requireActorClientKey(actor); - await this.expireClaimWorkflowLockV2IfStale(claimRequestId); - const claim = await this.claimCaseDbService.findById(claimRequestId); - - if (!claim) { - throw new NotFoundException('Claim request not found'); - } - - assertClaimCaseForTenant(claim, actor); - - const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); - const isDamageReviewLock = - claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; - - if (!isDamageReviewLock && !isFactorValidationLock) { - throw new BadRequestException( - `Claim is not available for locking. Current status: ${claim.status}`, + try { + const result = await this.assignClaimForReviewV2(claimRequestId, actor); + const response: Record = { + claimRequestId, + locked: true, + message: result.message, + }; + if (result.status === "assigned") { + const claim = await this.claimCaseDbService.findById(claimRequestId); + if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) { + response.status = ClaimCaseStatus.EXPERT_REVIEWING; + response.claimStatus = ClaimStatus.UNDER_REVIEW; + response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT; + } + } + return response; + } catch (error) { + if (error instanceof ConflictException) { + const body = error.getResponse(); + throw new BadRequestException(body); + } + if (error instanceof HttpException) throw error; + this.logger.error( + "lockClaimRequestV2 failed", + claimRequestId, + error instanceof Error ? error.stack : String(error), + ); + throw new HttpException( + error instanceof Error ? error.message : "Failed to lock claim", + HttpStatus.INTERNAL_SERVER_ERROR, ); } - - if ( - claim.workflow?.locked && - claim.workflow.lockedBy?.actorId?.toString() !== actor.sub - ) { - throw new BadRequestException( - `Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`, - ); - } - - // Already locked by this same expert — idempotent - if ( - claim.workflow?.locked && - claim.workflow.lockedBy?.actorId?.toString() === actor.sub - ) { - return { claimRequestId, locked: true, message: 'Already locked by you' }; - } - - const lockSnapshot = await this.snapshotDamageExpert(actor.sub); - const lockAt = new Date(); - const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs); - - // Common lock fields written in both branches. - const baseLockUpdate: Record = { - 'workflow.locked': true, - 'workflow.lockedAt': lockAt, - 'workflow.expiredAt': expiredAt, - 'workflow.lockedBy': { - actorId: new Types.ObjectId(actor.sub), - actorName: actor.fullName, - actorRole: 'damage_expert', - ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), - }, - 'workflow.preLockQueueSnapshot': { - claimStatus: claim.claimStatus, - currentStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow?.currentStep, - ClaimWorkflowStep.USER_SUBMISSION_COMPLETE, - ), - ...(claim.workflow?.nextStep != null - ? { - nextStep: this.normalizeClaimWorkflowStepForSnapshot( - claim.workflow.nextStep, - ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - ), - } - : {}), - }, - $push: { - history: { - type: "CLAIM_LOCKED", - actor: { - actorId: new Types.ObjectId(actor.sub), - actorName: actor.fullName, - actorType: "damage_expert", - }, - timestamp: new Date(), - metadata: { - note: isFactorValidationLock - ? `Claim locked for factor validation by damage expert ${actor.fullName}` - : `Claim locked by damage expert ${actor.fullName}`, - ...(isFactorValidationLock ? { phase: "factorValidation" } : {}), - }, - }, - }, - }; - - // Damage-review lock advances status/step; factor-validation lock keeps them - // intact so the claim remains in the factor-validation queue when the lock expires. - const update: Record = isFactorValidationLock - ? baseLockUpdate - : { - ...baseLockUpdate, - status: ClaimCaseStatus.EXPERT_REVIEWING, - claimStatus: ClaimStatus.UNDER_REVIEW, - 'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - }; - - await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, update); - - await this.recordClaimExpertActivity({ - expertId: String(actor.sub), - tenantId: this.claimActivityTenantId(claim, actor), - claimId: String(claimRequestId), - eventType: ExpertFileActivityType.CHECKED, - idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`, - }); - - const ownerPhone = await this.resolveClaimOwnerPhone(claim); - if (ownerPhone) { - const expertLastName = - actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; - await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( - { - receptor: ownerPhone, - fileKind: "claim", - publicId: claim.publicId, - expertLastName, - }, - ); - } - - return { - claimRequestId, - locked: true, - status: ClaimCaseStatus.EXPERT_REVIEWING, - claimStatus: ClaimStatus.UNDER_REVIEW, - currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT, - }; } async submitResendDocsV2( diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 170c7b9..3689e87 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common"; +import { Body, Controller, Get, Header, Param, Headers, Patch, Post, Put, UseGuards, Query, Res } from "@nestjs/common"; import { ApiBearerAuth, ApiBody, @@ -15,6 +15,7 @@ import { RolesGuard } from "src/auth/guards/role.guard"; import { Roles } from "src/decorators/roles.decorator"; import { CurrentUser } from "src/decorators/user.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto"; import { ExpertClaimService } from "./expert-claim.service"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto"; @@ -161,6 +162,29 @@ export class ExpertClaimV2Controller { ); } + @Post("assign/:claimRequestId") + @ApiOperation({ + summary: "Check availability and assign claim to this damage expert", + description: + "Call before opening a case from the list. Returns `assigned` when the claim was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Works for both the damage-review queue (`WAITING_FOR_DAMAGE_EXPERT`) and factor-validation queue.", + }) + @ApiParam({ name: "claimRequestId" }) + @ApiResponse({ status: 200, type: ExpertFileAssignResultDto }) + @ApiResponse({ + status: 409, + description: "Another expert is processing this claim", + type: ExpertFileAssignResultDto, + }) + async assignClaimForReviewV2( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() actor, + ) { + return await this.expertClaimService.assignClaimForReviewV2( + claimRequestId, + actor, + ); + } + @Put("lock/:claimRequestId") @ApiOperation({ summary: "Lock a claim request for review", @@ -168,7 +192,7 @@ export class ExpertClaimV2Controller { "Lockable in two queues:\n" + "1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" + "2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" + - "Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.", + "Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.", }) @ApiParam({ name: "claimRequestId" }) async lockClaimRequestV2( diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts index e89cf32..2475975 100644 --- a/src/request-management/entities/schema/blame-cases.schema.ts +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -99,4 +99,5 @@ export class BlameRequest { } export type BlameRequestDocument = HydratedDocument; -export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); \ No newline at end of file +export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); +BlameRequestSchema.index({ status: 1, "workflow.locked": 1 }); \ No newline at end of file