update some important issues

This commit is contained in:
2026-04-18 10:49:22 +03:30
parent 494e3d93ab
commit 4bdb9fd469
22 changed files with 1727 additions and 105 deletions

View File

@@ -25,6 +25,7 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { ResendRequestDto } from "./dto/resend.dto";
@@ -32,6 +33,7 @@ import { readFile } from "fs/promises";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
import { RequestManagementService } from "src/request-management/request-management.service";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -58,6 +60,10 @@ function statementToFormKey(
@Injectable()
export class ExpertBlameService {
private readonly logger = new Logger(ExpertBlameService.name);
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
constructor(
private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService,
@@ -67,6 +73,7 @@ export class ExpertBlameService {
private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
) { }
async findAll(actor: any): Promise<AllRequestDtoRs> {
@@ -234,6 +241,35 @@ export class ExpertBlameService {
return false;
});
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
const la = w.lockedAt;
if (!la) {
staleIds.add(String(doc._id));
continue;
}
if (
Date.now() >=
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
) {
staleIds.add(String(doc._id));
}
}
await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
);
for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue;
const w = doc.workflow as Record<string, unknown>;
if (w) {
w.locked = false;
delete w.lockedAt;
delete w.lockedBy;
}
}
const items: AllRequestDtoV2[] = visibleCases.map(
(doc) => this.mapBlameRequestToListItemV2(doc),
);
@@ -289,6 +325,75 @@ export class ExpertBlameService {
};
}
/**
* 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.
*/
private async expireBlameCaseWorkflowLockV2IfStale(
requestId: string,
): Promise<void> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request?.workflow?.locked) {
return;
}
const lockedAt = request.workflow.lockedAt;
if (
lockedAt &&
Date.now() <
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) {
return;
}
const lockedById = request.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
"workflow.locked": true,
};
if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
String(lockedById),
);
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: { "workflow.locked": false },
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
},
{ new: false },
);
if (!cleared) {
return;
}
if (!request.expert?.decision && lockedById) {
const rid = new Types.ObjectId(requestId).toString();
try {
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(String(lockedById)) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: rid } as any,
},
);
this.logger.warn(
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
);
} catch (e) {
this.logger.warn(
`Expert stat rollback failed after blame lock expiry ${requestId}`,
e,
);
}
}
}
public unlockApi(request, timer) {
return setTimeout(async () => {
try {
@@ -610,6 +715,7 @@ export class ExpertBlameService {
async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) throw new NotFoundException("Request not found");
@@ -766,8 +872,8 @@ export class ExpertBlameService {
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
try {
const now = new Date();
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
// First, check the current state
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
@@ -799,7 +905,8 @@ export class ExpertBlameService {
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(request.workflow.lockedBy?.actorId);
@@ -862,8 +969,18 @@ export class ExpertBlameService {
requestId: string,
resendDto: ResendRequestDto,
actorId: string,
): Promise<{ requestId: string; status: string }> {
): Promise<{
requestId: string;
status: string;
parties: Array<{
partyId: string;
requestedItems: ResendItemType[];
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
description?: string;
}>;
}> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
@@ -887,7 +1004,8 @@ export class ExpertBlameService {
// Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() > lockExpiryTime) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.",
@@ -909,16 +1027,42 @@ export class ExpertBlameService {
);
}
const allowedItems = new Set<string>(Object.values(ResendItemType));
const partyIdsOnFile = new Set(
(request.parties || [])
.map((p) => (p.person?.userId ? String(p.person.userId) : ""))
.filter(Boolean),
);
for (const party of resendDto.parties) {
const uid = String(party.partyId);
if (!partyIdsOnFile.has(uid)) {
throw new BadRequestException(
`partyId ${uid} is not a party userId on this blame request.`,
);
}
for (const item of party.requestedItems || []) {
if (!allowedItems.has(String(item))) {
throw new BadRequestException(
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
);
}
}
}
const now = new Date();
// Build resend requests for parties
const partyResendRequests = resendDto.parties.map((party) => ({
partyId: new Types.ObjectId(String(party.partyId)),
requestedItems: party.requestedItems,
description: party.description || "",
requestedAt: now,
completed: false,
}));
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
return {
partyId: new Types.ObjectId(uid),
requestedItems: party.requestedItems,
description: party.description || "",
requestedAt: now,
completed: false,
uploadedDocuments: {},
};
});
const updatePayload = {
$set: {
@@ -932,9 +1076,6 @@ export class ExpertBlameService {
},
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
@@ -952,11 +1093,21 @@ export class ExpertBlameService {
);
}
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
// TODO: Send notifications to parties (SMS/Push) about required documents
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
description: p.description || undefined,
})),
};
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -974,6 +1125,12 @@ export class ExpertBlameService {
/**
* V2: Submit expert reply for blame case (blameCases collection).
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
*
* Note: V1 `replyRequest` also handled an “objection” path (`expertResendReply`) by writing
* `expertSubmitReplyFinal` on the legacy request document. Blame V2 uses a single
* `expert.decision` on `blameCases`; resend is modeled as `expert.resend` *before* the first
* decision. If you need a second decision after signatures/objections, extend the schema
* (e.g. decision revision) and branch here similarly to V1.
*/
async replyRequestV2(
requestId: string,
@@ -981,6 +1138,7 @@ export class ExpertBlameService {
actorId: string,
): Promise<{ requestId: string; status: string }> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
@@ -1008,7 +1166,8 @@ export class ExpertBlameService {
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
@@ -1108,6 +1267,7 @@ export class ExpertBlameService {
actorId: string,
) {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
@@ -1135,7 +1295,8 @@ export class ExpertBlameService {
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
@@ -1555,4 +1716,5 @@ export class ExpertBlameService {
return updated;
}
}