1
0
forked from Yara724/api

YARA-951 case-4

This commit is contained in:
SepehrYahyaee
2026-05-25 13:11:37 +03:30
parent ff94fa35bf
commit 64fa560f73
5 changed files with 247 additions and 75 deletions

View File

@@ -66,6 +66,13 @@ export class ClaimWorkflow {
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
/**
* First damage expert who called review assign; kept after the 15m lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ClaimActorLockSchema })
assignedForReviewBy?: ClaimActorLock;
}
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -28,6 +28,12 @@ import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
blameWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import {
@@ -541,10 +547,18 @@ export class ExpertBlameService {
);
}
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
request.workflow,
);
const expireSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: { "workflow.locked": false },
$set: expireSet,
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
@@ -1056,24 +1070,29 @@ export class ExpertBlameService {
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
request.workflow,
request.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
request.workflow,
),
);
}
const lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
request.workflow?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: lockedById,
actorName: request.workflow?.lockedBy?.actorName,
lockedAt: request.workflow?.lockedAt
? new Date(request.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
@@ -1089,17 +1108,21 @@ export class ExpertBlameService {
const now = new Date();
const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
const lockUpdate = {
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorRole: "expert" as const,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const lockUpdate: {
$set: Record<string, unknown>;
$push: Record<string, unknown>;
} = {
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
"workflow.lockedBy": {
actorId: expertOid,
actorName: actor.fullName || "Unknown Expert",
actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
"workflow.lockedBy": lockedByPayload,
},
$push: {
history: {
@@ -1114,17 +1137,31 @@ export class ExpertBlameService {
},
},
};
if (!request.workflow?.assignedForReviewBy) {
lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload;
}
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
status: CaseStatus.WAITING_FOR_EXPERT,
blameStatus: BlameStatus.DISAGREEMENT,
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
if (request.expertInitiated) {
@@ -1140,6 +1177,19 @@ export class ExpertBlameService {
if (!updated) {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const latest = await this.blameRequestDbService.findById(requestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
BLAME_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
}
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
@@ -1147,18 +1197,9 @@ export class ExpertBlameService {
otherId &&
otherId !== actor.sub
) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: otherId,
actorName: latest.workflow?.lockedBy?.actorName,
lockedAt: latest.workflow?.lockedAt
? new Date(latest.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,

View File

@@ -35,6 +35,12 @@ import {
ExpertFileAssignResultDto,
ExpertFileAssignStatus,
} from "src/common/dto/expert-file-assign-result.dto";
import {
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
claimWorkflowAssigneeToPersistOnLockExpiry,
buildExpertAssignConflictForOtherReviewer,
resolvePersistentReviewAssigneeId,
} from "src/helpers/expert-workflow-review-assignee";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
@@ -2375,24 +2381,30 @@ export class ExpertClaimService {
});
}
const expertOid = new Types.ObjectId(actor.sub);
const reviewAssigneeId = resolvePersistentReviewAssigneeId(
claim.workflow,
claim.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (reviewAssigneeId && reviewAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
reviewAssigneeId,
claim.workflow,
),
);
}
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const lockEnforced =
claim.workflow?.locked &&
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
if (lockEnforced && lockedById && lockedById !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: lockedById,
actorName: claim.workflow?.lockedBy?.actorName,
lockedAt: claim.workflow?.lockedAt
? new Date(claim.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(lockedById, claim.workflow),
);
}
if (lockEnforced && lockedById === actor.sub) {
@@ -2406,21 +2418,22 @@ export class ExpertClaimService {
};
}
const expertOid = new Types.ObjectId(actor.sub);
const now = new Date();
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
const lockedByPayload = {
actorId: expertOid,
actorName: actor.fullName,
actorRole: "damage_expert" as const,
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
};
const baseLockUpdate: Record<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.lockedBy": lockedByPayload,
"workflow.preLockQueueSnapshot": {
claimStatus: claim.claimStatus,
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
@@ -2455,6 +2468,10 @@ export class ExpertClaimService {
},
};
if (!claim.workflow?.assignedForReviewBy) {
baseLockUpdate["workflow.assignedForReviewBy"] = lockedByPayload;
}
const lockUpdate: Record<string, unknown> = isFactorValidationLock
? baseLockUpdate
: {
@@ -2466,12 +2483,23 @@ export class ExpertClaimService {
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(claimRequestId),
$and: [
{
$or: [
{ "workflow.locked": { $ne: true } },
{ "workflow.locked": false },
{ "workflow.expiredAt": { $lte: now } },
{ "workflow.lockedBy.actorId": expertOid },
],
},
{
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
{ "workflow.assignedForReviewBy.actorId": expertOid },
],
},
],
};
if (isFactorValidationLock) {
@@ -2497,6 +2525,19 @@ export class ExpertClaimService {
if (!updated) {
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
const latest = await this.claimCaseDbService.findById(claimRequestId);
const otherAssigneeId = resolvePersistentReviewAssigneeId(
latest?.workflow,
latest?.history,
CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE,
);
if (otherAssigneeId && otherAssigneeId !== actor.sub) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(
otherAssigneeId,
latest?.workflow,
),
);
}
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
if (
latest?.workflow?.locked &&
@@ -2504,18 +2545,9 @@ export class ExpertClaimService {
otherId &&
otherId !== actor.sub
) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "Request is already being processed by another expert",
lockedBy: {
actorId: otherId,
actorName: latest.workflow?.lockedBy?.actorName,
lockedAt: latest.workflow?.lockedAt
? new Date(latest.workflow.lockedAt as Date).toISOString()
: undefined,
},
});
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,
@@ -3657,10 +3689,18 @@ export class ExpertClaimService {
"workflow.preLockQueueSnapshot": "",
};
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
claim.workflow,
);
const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireLockSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const update = needsQueueRestore
? {
$set: {
"workflow.locked": false,
...expireLockSet,
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: restoreClaimStatus,
"workflow.currentStep": restoreCurrent,
@@ -3669,7 +3709,7 @@ export class ExpertClaimService {
$unset: lockFieldsUnset,
}
: {
$set: { "workflow.locked": false },
$set: expireLockSet,
$unset: lockFieldsUnset,
};

View File

@@ -0,0 +1,77 @@
import type { ExpertFileAssignStatus } from "src/common/dto/expert-file-assign-result.dto";
export const BLAME_REVIEW_ASSIGNED_HISTORY_TYPE = "BLAME_ASSIGNED";
export const CLAIM_REVIEW_ASSIGNED_HISTORY_TYPE = "CLAIM_ASSIGNED";
type WorkflowAssigneeRef = {
assignedForReviewBy?: { actorId?: unknown; actorName?: string };
lockedBy?: { actorId?: unknown; actorName?: string };
lockedAt?: Date | string;
};
type HistoryAssigneeRef = {
type?: string;
actor?: { actorId?: unknown };
timestamp?: Date | string;
};
/** Expert who first assigned via the review assign endpoint (survives lock TTL expiry). */
export function resolvePersistentReviewAssigneeId(
workflow: WorkflowAssigneeRef | undefined,
history: HistoryAssigneeRef[] | undefined,
assignedHistoryType: string,
): string {
const fromField = workflow?.assignedForReviewBy?.actorId;
if (fromField != null && String(fromField).length > 0) {
return String(fromField);
}
const fromHistory = history?.find(
(h) => h.type === assignedHistoryType,
)?.actor?.actorId;
if (fromHistory != null && String(fromHistory).length > 0) {
return String(fromHistory);
}
return "";
}
export function buildExpertAssignConflictForOtherReviewer(
assigneeId: string,
workflow?: WorkflowAssigneeRef,
): {
success: false;
status: ExpertFileAssignStatus;
message: string;
lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
} {
const assignee =
workflow?.assignedForReviewBy ?? workflow?.lockedBy;
const lockedAt = workflow?.lockedAt;
return {
success: false,
status: "locked",
message: "Request is already being answered by another expert",
lockedBy: {
actorId: assigneeId,
actorName: assignee?.actorName,
lockedAt: lockedAt
? new Date(lockedAt as Date).toISOString()
: undefined,
},
};
}
/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */
export function blameWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
if (workflow?.assignedForReviewBy) {
return undefined;
}
return workflow?.lockedBy;
}
export function claimWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined {
return blameWorkflowAssigneeToPersistOnLockExpiry(workflow);
}

View File

@@ -48,5 +48,12 @@ export class Workflow {
@Prop({ type: ActorLockSchema })
lockedBy?: ActorLock;
/**
* First expert who called review assign; kept after the 15m workflow lock expires
* so no other expert can take the case while it remains in the expert queue.
*/
@Prop({ type: ActorLockSchema })
assignedForReviewBy?: ActorLock;
}
export const WorkflowSchema = SchemaFactory.createForClass(Workflow);