This commit is contained in:
SepehrYahyaee
2026-05-24 13:34:43 +03:30
parent 866696094f
commit 2bdd0d507e
9 changed files with 566 additions and 234 deletions

View File

@@ -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",

View File

@@ -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 {