forked from Yara724/api
YARA-951
This commit is contained in:
@@ -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<ExpertFileAssignResultDto> {
|
||||
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<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 (!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",
|
||||
|
||||
Reference in New Issue
Block a user