forked from Yara724/api
YARA-951
This commit is contained in:
@@ -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<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.
|
||||
*
|
||||
@@ -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<string, unknown> = {
|
||||
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<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,
|
||||
locked: true,
|
||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||
};
|
||||
}
|
||||
|
||||
async submitResendDocsV2(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user