1
0
forked from Yara724/api

Merge pull request 'YARA-951' (#83) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#83
This commit is contained in:
2026-05-24 13:34:44 +03:30
9 changed files with 566 additions and 234 deletions

View File

@@ -7,6 +7,12 @@ export class CaptchaResponseDto {
}) })
captchaId: string; captchaId: string;
@ApiProperty({
description: "Sample text",
example: "sb20xe"
})
text: string
@ApiProperty({ @ApiProperty({
description: description:
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.", "SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",

View File

@@ -35,6 +35,7 @@ export class CaptchaChallengeService {
return { return {
captchaId, captchaId,
text: generated.text,
image: generated.image, image: generated.image,
expiresAt: generated.expiresAt, expiresAt: generated.expiresAt,
}; };

View File

@@ -159,5 +159,5 @@ export class ClaimCase {
export type ClaimCaseDocument = HydratedDocument<ClaimCase>; export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });

View File

@@ -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;
};
}

View File

@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
HttpException, HttpException,
Injectable, Injectable,
@@ -23,6 +24,10 @@ import {
} from "src/helpers/expert-portfolio"; } from "src/helpers/expert-portfolio";
import { applyListQueryV2 } from "src/helpers/list-query-v2"; import { applyListQueryV2 } from "src/helpers/list-query-v2";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; 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 { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { import {
@@ -1000,118 +1005,222 @@ export class ExpertBlameService {
} }
/** /**
* V2: Lock blame case for 15 minutes (blameCases collection). * Assign the current field expert to a blame case for review (V2).
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt. * Free cases are locked atomically; already-assigned-to-self is idempotent.
* Checks if existing lock is expired and allows re-locking.
*/ */
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { async assignBlameCaseForReviewV2(
try { requestId: string,
const now = new Date(); actor: { sub: string; fullName?: string; clientKey?: string },
): Promise<ExpertFileAssignResultDto> {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
// First, check the current state
const request = await this.blameRequestDbService.findById(requestId); const request = await this.blameRequestDbService.findById(requestId);
if (!request) { if (!request) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
assertBlameCaseForExpertTenant(request, actorDetail); assertBlameCaseForExpertTenant(request, actor);
if (request.type === BlameRequestType.CAR_BODY) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "CAR_BODY cases do not require expert review",
});
}
// Validate request is available for expert review
if ( if (
request.status !== CaseStatus.WAITING_FOR_EXPERT || request.status !== CaseStatus.WAITING_FOR_EXPERT ||
request.blameStatus !== BlameStatus.DISAGREEMENT request.blameStatus !== BlameStatus.DISAGREEMENT
) { ) {
throw new BadRequestException( throw new BadRequestException({
"Request is not available for expert review", success: false,
); status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
} }
// Expert-initiated: only the initiating field expert can lock and review
if ( if (
request.expertInitiated && request.expertInitiated &&
request.initiatedByFieldExpertId && request.initiatedByFieldExpertId &&
String(request.initiatedByFieldExpertId) !== actorDetail.sub String(request.initiatedByFieldExpertId) !== actor.sub
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
"Only the field expert who created this file can lock and review it.", "Only the field expert who created this file can review it",
); );
} }
// Check if locked and not expired (`expiredAt` or lockedAt + TTL) const expertOid = new Types.ObjectId(actor.sub);
if ( const lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
request.workflow?.locked && request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request) this.isBlameV2WorkflowLockCurrentlyEnforced(request);
) {
const lockedByActorId = String( if (lockEnforced && lockedById && lockedById !== actor.sub) {
request.workflow.lockedBy?.actorId ?? "", throw new ConflictException({
); success: false,
if (lockedByActorId === actorDetail.sub) { status: "locked" satisfies ExpertFileAssignStatus,
throw new BadRequestException( message: "Request is already being processed by another expert",
"You have already locked this request", lockedBy: {
); actorId: lockedById,
} actorName: request.workflow?.lockedBy?.actorName,
throw new BadRequestException( lockedAt: request.workflow?.lockedAt
"Request is currently locked by another expert", ? new Date(request.workflow.lockedAt as Date).toISOString()
); : undefined,
},
});
} }
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); 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,
};
}
// Lock the request (either unlocked or expired lock) const now = new Date();
const updateResult = await this.blameRequestDbService.findByIdAndUpdate( const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
requestId, const lockUpdate = {
{
$set: { $set: {
"workflow.locked": true, "workflow.locked": true,
"workflow.lockedAt": now, "workflow.lockedAt": now,
"workflow.expiredAt": new Date( "workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
now.getTime() + this.blameV2LockTtlMs,
),
"workflow.lockedBy": { "workflow.lockedBy": {
actorId: new Types.ObjectId(actorDetail.sub), actorId: expertOid,
actorName: actorDetail.fullName || "Unknown Expert", actorName: actor.fullName || "Unknown Expert",
actorRole: "expert", actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), ...(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<string, unknown> = {
_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 (!updateResult) { if (!updated) {
throw new InternalServerErrorException("Failed to lock the request"); 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",
});
} }
await this.recordBlameExpertActivity({ await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub), expertId: String(actor.sub),
requestId: String(requestId), requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED, eventType: ExpertFileActivityType.CHECKED,
tenantId: tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") || String(updated.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""), String(updated.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`, idempotencyKey: `blame:${requestId}:checked:${actor.sub}`,
}); });
if (request.type === BlameRequestType.THIRD_PARTY) { if (updated.type === BlameRequestType.THIRD_PARTY) {
const phones = (request.parties || []) const phones = (updated.parties || [])
.map((p: any) => p?.person?.phoneNumber) .map((p: any) => p?.person?.phoneNumber)
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0); .filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
const expertLastName = const expertLastName =
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) { for (const phone of phones) {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{ {
receptor: phone, receptor: phone,
fileKind: "blame", fileKind: "blame",
publicId: request.publicId, publicId: updated.publicId,
expertLastName, expertLastName,
}, },
); );
} }
} }
return { _id: requestId, lock: true }; 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) { } catch (error) {
if (error instanceof ConflictException) {
const body = error.getResponse();
throw new BadRequestException(body);
}
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
"lockRequestV2 failed", "lockRequestV2 failed",

View File

@@ -5,11 +5,13 @@ import {
HttpException, HttpException,
InternalServerErrorException, InternalServerErrorException,
Param, Param,
Post,
Put, Put,
Query, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } 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 { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard"; import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; 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") @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" }) @ApiParam({ name: "id", description: "Blame case request id" })
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) { async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
try { try {

View File

@@ -31,6 +31,10 @@ import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.e
import { UserType } from "src/Types&Enums/userType.enum"; import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { UserDbService } from "src/users/entities/db-service/user.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 { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
@@ -2341,6 +2345,218 @@ export class ExpertClaimService {
throw new HttpException(error.response, error.claimStatus); 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<ExpertFileAssignResultDto> {
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<string, unknown> = {
"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<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
...baseLockUpdate,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
const assignFilter: Record<string, unknown> = {
_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. * 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 * `workflow.currentStep` untouched so the claim stays in the factor-validation
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep * queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
* treating it correctly when the lock expires). * treating it correctly when the lock expires).
*
* Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients.
*/ */
async lockClaimRequestV2(claimRequestId: string, actor: any) { async lockClaimRequestV2(claimRequestId: string, actor: any) {
requireActorClientKey(actor); try {
await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const result = await this.assignClaimForReviewV2(claimRequestId, actor);
const claim = await this.claimCaseDbService.findById(claimRequestId); const response: Record<string, unknown> = {
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}`,
);
}
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<string, unknown> = {
'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<string, unknown> = 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, claimRequestId,
locked: true, locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING, message: result.message,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
}; };
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,
);
}
} }
async submitResendDocsV2( async submitResendDocsV2(

View File

@@ -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 { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -15,6 +15,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; 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 { ExpertClaimService } from "./expert-claim.service";
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
import { GetClaimListV2ResponseDto } from "./dto/claim-list-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") @Put("lock/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Lock a claim request for review", summary: "Lock a claim request for review",
@@ -168,7 +192,7 @@ export class ExpertClaimV2Controller {
"Lockable in two queues:\n" + "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" + "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" + "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" }) @ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2( async lockClaimRequestV2(

View File

@@ -100,3 +100,4 @@ export class BlameRequest {
export type BlameRequestDocument = HydratedDocument<BlameRequest>; export type BlameRequestDocument = HydratedDocument<BlameRequest>;
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);
BlameRequestSchema.index({ status: 1, "workflow.locked": 1 });