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