This commit is contained in:
SepehrYahyaee
2026-04-26 11:43:17 +03:30
parent 4f8cb43883
commit b5b3b722c6
6 changed files with 267 additions and 150 deletions

View File

@@ -46,6 +46,7 @@ import { RequestManagementService } from "src/request-management/request-managem
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -407,6 +408,27 @@ export class ExpertBlameService {
};
}
/** True while another expert holds an active workflow lock (uses `expiredAt` when set). */
private isBlameV2WorkflowLockCurrentlyEnforced(doc: {
workflow?: {
locked?: boolean;
lockedAt?: Date;
expiredAt?: Date;
lockedBy?: { actorId?: unknown };
};
}): boolean {
if (!doc?.workflow?.locked) return false;
const exp = doc.workflow.expiredAt;
if (exp) {
return Date.now() < new Date(exp as Date).getTime();
}
const la = doc.workflow.lockedAt;
if (!la) return true;
return (
Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs
);
}
/**
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
@@ -420,6 +442,13 @@ export class ExpertBlameService {
}
const lockedAt = request.workflow.lockedAt;
const expiredAt = request.workflow.expiredAt;
if (
expiredAt &&
Date.now() < new Date(expiredAt as Date).getTime()
) {
return;
}
if (
lockedAt &&
Date.now() <
@@ -433,7 +462,9 @@ export class ExpertBlameService {
_id: new Types.ObjectId(requestId),
"workflow.locked": true,
};
if (lockedAt) {
if (expiredAt) {
filter["workflow.expiredAt"] = expiredAt;
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
@@ -715,6 +746,7 @@ export class ExpertBlameService {
try {
requireActorClientKey(actor);
const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
@@ -736,6 +768,16 @@ export class ExpertBlameService {
);
}
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) {
const w = doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined;
const lockerId = String(w?.lockedBy?.actorId ?? "");
if (lockerId && lockerId !== actorId) {
throw new ForbiddenException(
"This request is locked by another expert.",
);
}
}
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
@@ -924,29 +966,22 @@ export class ExpertBlameService {
);
}
// Check if locked and not expired
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
} else {
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
}
// Lock expired, allow re-locking (continue below)
// 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);
@@ -988,6 +1023,7 @@ export class ExpertBlameService {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
expertLastName,
},
@@ -1153,30 +1189,48 @@ export class ExpertBlameService {
requestId,
);
const requestIdToken = String(request._id);
const partyPhoneByUserId = new Map<string, string>();
for (const p of request.parties || []) {
const uid = p?.person?.userId ? String(p.person.userId) : "";
const phone = p?.person?.phoneNumber;
if (uid && typeof phone === "string" && phone.length > 0) {
partyPhoneByUserId.set(uid, phone);
if (request.type === BlameRequestType.THIRD_PARTY) {
const requestIdToken = String(request._id);
const partyPhoneByUserId = new Map<string, string>();
for (const p of request.parties || []) {
const uid = p?.person?.userId ? String(p.person.userId) : "";
const phone = p?.person?.phoneNumber;
if (uid && typeof phone === "string" && phone.length > 0) {
partyPhoneByUserId.set(uid, phone);
}
}
const partyHasResendPayload = (row: {
requestedItems?: ResendItemType[];
description?: string;
}) => {
const n = Array.isArray(row.requestedItems)
? row.requestedItems.length
: 0;
return n > 0 || String(row.description ?? "").trim().length > 0;
};
for (const p of partyResendRequests) {
if (!partyHasResendPayload(p)) continue;
const uid = String((p as any).partyId);
const phone = partyPhoneByUserId.get(uid);
if (!phone) continue;
const role =
String(
request.parties?.find(
(x: any) => String(x?.person?.userId) === uid,
)?.role,
) === "SECOND"
? "SECOND"
: "FIRST";
await this.smsOrchestrationService.sendResendDocumentsNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
role,
),
});
}
}
for (const p of partyResendRequests) {
const uid = String((p as any).partyId);
const phone = partyPhoneByUserId.get(uid);
if (!phone) continue;
const role =
String(request.parties?.find((x: any) => String(x?.person?.userId) === uid)?.role) ===
"SECOND"
? "SECOND"
: "FIRST";
await this.smsOrchestrationService.sendResendDocumentsNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
link: this.smsOrchestrationService.buildBlamePartyLink(requestIdToken, role),
});
}
return {
@@ -1333,14 +1387,17 @@ export class ExpertBlameService {
);
}
// THIRD_PARTY: both parties must sign; send each their deep link (mutual-agreement uses yara-blame-agreement elsewhere).
if (request.type === BlameRequestType.THIRD_PARTY) {
const requestIdToken = String(request._id);
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const p of request.parties || []) {
const parties = request.parties || [];
for (const partyRole of [PartyRole.FIRST, PartyRole.SECOND] as const) {
const p = parties.find((x: any) => x?.role === partyRole);
const phone = p?.person?.phoneNumber;
if (!phone || typeof phone !== "string") continue;
const role = String(p?.role) === "SECOND" ? "SECOND" : "FIRST";
const linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: phone,
fileKind: "blame",
@@ -1348,7 +1405,7 @@ export class ExpertBlameService {
expertLastName,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
role,
linkRole,
),
});
}