Files
yara724api/src/expert-blame/expert-blame.service.ts
2026-06-18 18:15:20 +03:30

2335 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import {
assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert,
blameCaseInitiatedByFieldExpert,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
blameCaseStatusToReportBucket,
initialBlameExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import {
blameDocInExpertPortfolio,
expertPortfolioFileIdsFromActivityEvents,
objectIdsFromStringSet,
} 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 {
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 {
AllRequestDtoRs,
AllRequestDtoV2,
AllRequestDtoRsV2,
} from "./dto/all-request.dto";
import { UserType } from "src/Types&Enums/userType.enum";
import { SubmitReplyDto } from "./dto/reply.dto";
import { Types } from "mongoose";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
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,
normalizeResendRequestedItemsList,
} 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";
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";
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";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import {
ExpertFileActivityType,
ExpertFileKind,
} from "src/users/entities/schema/expert-file-activity.schema";
interface CheckedRequestEntry {
CheckedRequest?: {
actorId: string;
fullName: string;
};
[key: string]: any;
}
function statementToFormKey(statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
}): string | null {
if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged";
if (statement.acceptsExpertOpinion) return "expertOpinion";
return null;
}
@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,
private readonly clientDbService: ClientDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) {}
/** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) {
const expertDoc = await this.expertDbService.findOne({
_id: new Types.ObjectId(actorId),
});
return snapshotFromFieldExpert(expertDoc as ExpertModel);
}
async findAll(actor: any): Promise<AllRequestDtoRs> {
// 1. Fetch all potentially relevant requests from the database.
// Exclude CAR_BODY type requests as they are automatically handled and don't need expert review
const allRequests = await this.requestManagementDbService.findAll({
"firstPartyDetails.firstPartyPlate": { $ne: null },
"secondPartyDetails.secondPartyPlate": { $ne: null },
blameStatus: {
$in: [
ReqBlameStatus.UnChecked,
ReqBlameStatus.CloseRequest,
ReqBlameStatus.CheckAgain,
ReqBlameStatus.ReviewRequest,
],
},
// Both parties must have submitted their initial forms
"firstPartyDetails.firstPartyInitialForm": { $exists: true },
"secondPartyDetails.secondPartyInitialForm": { $exists: true },
type: { $ne: "CAR_BODY" }, // Exclude CAR_BODY type requests
});
// 2. Filter requests that need expert review based on initial form logic
// Expert is needed when there's a conflict (both claim damaged, both claim guilty, etc.)
// Expert is NOT needed when one says imDamaged and the other says imGuilty (auto-resolved)
const requestsNeedingExpert = [];
for (const request of allRequests) {
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
const secondPartyForm =
request.secondPartyDetails?.secondPartyInitialForm;
if (!firstPartyForm || !secondPartyForm) {
continue; // Skip if forms are not complete
}
// Check if this can be auto-resolved (one says damaged, other says guilty)
const canAutoResolve =
(firstPartyForm.imDamaged && secondPartyForm.imGuilty) ||
(secondPartyForm.imDamaged && firstPartyForm.imGuilty);
// If it can be auto-resolved, skip it (no expert needed)
if (canAutoResolve) {
continue;
}
// Otherwise, expert is needed (both damaged, both guilty, or other conflicts)
requestsNeedingExpert.push(request);
}
// 3. Filter the requests in memory based on the expert's specific access rights.
const visibleRequests = [];
for (const request of requestsNeedingExpert) {
// For expert-initiated files, only show to the initiating expert
if (request.expertInitiated && request.initiatedBy) {
if (String(request.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated files are always visible to the initiating expert
visibleRequests.push(request);
continue;
}
// For normal files, use existing client-based filtering
const firstPartyClientId =
request.firstPartyDetails?.firstPartyClient?.clientId?.toString();
const secondPartyClientId =
request.secondPartyDetails?.secondPartyClient?.clientId?.toString();
const partyClientIds = [firstPartyClientId, secondPartyClientId]
.filter(Boolean)
.map((id) => new Types.ObjectId(id));
if (partyClientIds.length === 0) {
continue;
}
let clientQuery: any = { _id: { $in: partyClientIds } };
if (actor.userType === UserType.LEGAL) {
clientQuery = {
$and: [
{ _id: { $in: partyClientIds } },
{ _id: new Types.ObjectId(actor.clientKey) },
],
};
}
const client = await this.clientDbService.findOne(clientQuery);
if (!client) {
continue;
}
const isExpertTypeMatch = client.useExpertMode === actor.userType;
if (!isExpertTypeMatch) {
continue;
}
if (request.blameStatus === ReqBlameStatus.CheckAgain) {
if (String(request.actorLocked?.actorId) === actor.sub) {
visibleRequests.push(request);
}
} else {
visibleRequests.push(request);
}
}
return new AllRequestDtoRs(visibleRequests);
}
/**
* V2: List blame cases for current expert.
* Shows:
* 1. Fresh requests (WAITING_FOR_EXPERT with no decision)
* 2. Requests where current expert made the decision
* Does NOT show requests decided by other experts.
*/
/**
* V2: Count blame cases in this field experts portfolio, grouped for dashboard.
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const expertId = String(actor.sub);
const expertOid = new Types.ObjectId(expertId);
const activityEvents = await this.expertFileActivityDbService.findByExpert(
expertId,
ExpertFileKind.BLAME,
);
const activityFileIds =
expertPortfolioFileIdsFromActivityEvents(activityEvents);
const orClauses: Record<string, unknown>[] = [
{ initiatedByFieldExpertId: expertOid },
{ "expert.decision.decidedByExpertId": expertOid },
{ "expert.resend.requestedByExpertId": expertOid },
{ "workflow.lockedBy.actorId": expertOid },
];
const activityOids = objectIdsFromStringSet(activityFileIds);
if (activityOids.length > 0) {
orClauses.push({ _id: { $in: activityOids } });
}
const rows = await this.blameRequestDbService.find(
{
blameStatus: BlameStatus.DISAGREEMENT,
$or: orClauses,
},
{ lean: true },
);
const buckets = initialBlameExpertReportBuckets();
const seen = new Set<string>();
for (const doc of rows as Record<string, unknown>[]) {
const id = String(doc._id ?? "");
if (seen.has(id)) continue;
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
seen.add(id);
const key = blameCaseStatusToReportBucket(String(doc.status ?? ""));
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
// Adding extra fields for front-end
buckets["PENDING_ON_USER"] =
buckets[CaseStatus.WAITING_FOR_DOCUMENT_RESEND] +
buckets[CaseStatus.WAITING_FOR_SIGNATURES];
buckets["CHECK_NEEDED"] =
buckets["PENDING_ON_USER"] + buckets[CaseStatus.WAITING_FOR_EXPERT];
return buckets;
}
async findAllV2(
actor: any,
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
try {
if (actor.role === RoleEnum.FIELD_EXPERT) {
return this.getFieldExpertBlameListV2(actor, query);
}
requireActorClientKey(actor);
const expertId = actor.sub;
const allCases = await this.blameRequestDbService.find(
{ blameStatus: BlameStatus.DISAGREEMENT },
{ lean: true },
);
const visibleCases = (allCases as Record<string, unknown>[]).filter(
(doc) => {
// Always filter by tenant first
if (!blameCaseAccessibleToExpert(doc, actor)) return false;
const status = doc.status as string;
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId
? String(decision.decidedByExpertId)
: null;
const lockedById = String(
(doc.workflow as any)?.lockedBy?.actorId ?? "",
);
const lockEnforced =
(doc.workflow as any)?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any);
// Expert-initiated files: only the initiating expert sees them
if (doc.expertInitiated === true && doc.initiatedByFieldExpertId) {
return String(doc.initiatedByFieldExpertId) === expertId;
}
// Bucket 1: Available — waiting, no decision, no active lock by someone else
const isAvailable =
status === CaseStatus.WAITING_FOR_EXPERT &&
!decidedByExpertId &&
(!lockEnforced || lockedById === expertId);
// Bucket 2: Mine — decided by me
const isDecidedByMe =
!!decidedByExpertId && decidedByExpertId === expertId;
// Bucket 3: Mine — currently locked/assigned to me (in progress)
const isLockedByMe = lockEnforced && lockedById === expertId;
// Bucket 4: Mine — persistently assigned to me for review
const assignedForReviewById = String(
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
);
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === expertId;
return isAvailable || isDecidedByMe || isLockedByMe || isAssignedToMe;
},
);
// Reconcile stale locks in-memory (same as before)
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
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.expiredAt;
delete w.lockedBy;
}
}
const paged = applyListQueryV2(
visibleCases,
{
publicId: (doc) =>
String((doc as { publicId?: string }).publicId ?? ""),
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
requestNo: (doc) =>
String(
(doc as { requestNo?: string }).requestNo ??
(doc as { publicId?: string }).publicId ??
"",
),
status: (doc) => String((doc as { status?: string }).status ?? ""),
searchExtras: (doc) => {
const d = doc as {
_id?: unknown;
blameStatus?: string;
type?: string;
};
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
Boolean,
) as string[];
},
},
query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc),
);
return new AllRequestDtoRsV2({
data: items,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
});
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"findAllV2 failed",
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases",
);
}
}
/**
* Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created
* (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually
* via expert-claim after blame completion.
*/
private async getFieldExpertBlameListV2(
actor: { sub: string },
query: ListQueryV2Dto = {},
): Promise<AllRequestDtoRsV2> {
const expertId = actor.sub;
const expertOid = new Types.ObjectId(expertId);
const visibleCases = (await this.blameRequestDbService.find(
{
expertInitiated: true,
initiatedByFieldExpertId: expertOid,
},
{ lean: true },
)) as Record<string, unknown>[];
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
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.expiredAt;
delete w.lockedBy;
}
}
const paged = applyListQueryV2(
visibleCases,
{
publicId: (doc) =>
String((doc as { publicId?: string }).publicId ?? ""),
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
requestNo: (doc) =>
String(
(doc as { requestNo?: string }).requestNo ??
(doc as { publicId?: string }).publicId ??
"",
),
status: (doc) => String((doc as { status?: string }).status ?? ""),
searchExtras: (doc) => {
const d = doc as {
_id?: unknown;
blameStatus?: string;
type?: string;
};
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
Boolean,
) as string[];
},
},
query,
);
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
this.mapBlameRequestToListItemV2(doc),
);
return new AllRequestDtoRsV2({
data: items,
total: paged.total,
page: paged.page,
limit: paged.limit,
totalPages: paged.totalPages,
});
}
private mapBlameRequestToListItemV2(
doc: Record<string, unknown>,
): AllRequestDtoV2 {
const createdAt = doc.createdAt
? new Date(doc.createdAt as string)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string)
: new Date();
const [date, time] = toJalaliDateAndTime(createdAt);
const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
const parties = (doc.parties ?? []) as Array<{
role?: string;
vehicle?: {
name?: string;
inquiry?: {
mapped?: {
MapTypNam?: string;
CarName?: string;
};
};
};
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const firstParty = parties.find((p) => p.role === "FIRST");
const secondParty = parties.find((p) => p.role === "SECOND");
return {
requestId: String(doc._id),
status: String(doc.status ?? ""),
userComment: null,
requestCode: String(doc.publicId ?? ""),
date,
time,
updatedAtDate,
updatedAtTime,
lockFile: Boolean(workflow.locked),
lockTime: workflow.lockedAt
? new Date(workflow.lockedAt as string).toISOString()
: null,
type: String(doc.type ?? "THIRD_PARTY"),
blameStatus: String(doc.blameStatus ?? ""),
partiesInitialForms: {
firstParty: statementToFormKey(firstParty?.statement) ?? "",
secondParty: statementToFormKey(secondParty?.statement) ?? "",
},
partiesVehicles: {
firstPartyVehicle:
firstParty?.vehicle?.name ||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
firstParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
secondPartyVehicle:
secondParty?.vehicle?.name ||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
},
};
}
/** 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.
*/
private async expireBlameCaseWorkflowLockV2IfStale(
requestId: string,
): Promise<void> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request?.workflow?.locked) {
return;
}
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() < 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 (expiredAt) {
filter["workflow.expiredAt"] = expiredAt;
} else if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
String(lockedById),
);
}
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
request.workflow,
request,
);
const expireSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) {
expireSet["workflow.assignedForReviewBy"] = assigneeToPersist;
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: expireSet,
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
},
{ new: false },
);
if (!cleared) {
return;
}
if (!request.expert?.decision && lockedById) {
try {
await this.recordBlameExpertActivity({
expertId: String(lockedById),
requestId: String(requestId),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:unchecked:expired:${lockedById}`,
});
this.logger.warn(
`Blame case ${requestId} workflow lock expired; unlocked in DB and emitted unchecked activity for expert ${lockedById}`,
);
} catch (e) {
this.logger.warn(
`Expert activity emit failed after blame lock expiry ${requestId}`,
e,
);
}
}
}
public unlockApi(request, timer) {
return setTimeout(async () => {
try {
const r = await this.requestManagementDbService.findOne(request._id);
const updateExp: any = {
lockFile: false,
unlockTime: null,
};
const shouldDecrementChecked =
r.blameStatus === ReqBlameStatus.ReviewRequest &&
!r.expertSubmitReply &&
r.actorLocked?.actorId;
if (shouldDecrementChecked) {
updateExp.blameStatus = ReqBlameStatus.UnChecked;
await this.recordBlameExpertActivity({
expertId: String(r.actorLocked.actorId),
requestId: String(r._id),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(r.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(r.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${String(r._id)}:unchecked:unlock:${String(r.actorLocked.actorId)}`,
});
this.logger.warn(
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
);
}
await this.requestManagementDbService.findByIdAndUpdate(
r._id.toString(),
updateExp,
);
this.logger.log(`Unlock completed for request: ${r._id}`);
} catch (error) {
this.logger.error(`Failed to unlock request ${request._id}`, error);
}
}, timer);
}
public scheduleUnlock(request) {
const unlockDelay = new Date(request.unlockTime).getTime() - Date.now();
if (unlockDelay <= 0) return; // already expired
setTimeout(async () => {
try {
// Double-check latest state before unlocking
const current = await this.requestManagementDbService.findOne(
request._id,
);
if (!current.lockFile || current.expertSubmitReply) {
// Already unlocked or replied
return;
}
// If expiry passed
if (current.unlockTime && new Date(current.unlockTime) <= new Date()) {
const shouldRollbackStats =
current.blameStatus === ReqBlameStatus.ReviewRequest &&
!current.expertSubmitReply &&
current.actorLocked?.actorId;
const update: any = {
lockFile: false,
unlockTime: null,
lockTime: null,
};
if (shouldRollbackStats) {
update.blameStatus = ReqBlameStatus.UnChecked;
await this.recordBlameExpertActivity({
expertId: String(current.actorLocked.actorId),
requestId: String(current._id),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId:
String(
current.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
current.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${String(current._id)}:unchecked:auto-unlock:${String(current.actorLocked.actorId)}`,
});
this.logger.warn(
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
);
}
await this.requestManagementDbService.findByIdAndUpdate(
String(current._id),
update,
);
this.logger.log(`Auto-unlock completed for request: ${current._id}`);
}
} catch (err) {
this.logger.error(`Auto-unlock failed for ${request._id}`, err);
}
}, unlockDelay);
}
async findOne(requestId: string, actorId: string) {
// 1. Fetch the main request document
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// 1.5. Reject CAR_BODY type requests as they don't need expert review
if (request.type === "CAR_BODY") {
throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.",
);
}
// 2. Initial validation to ensure the expert has access
// Check if locked by current expert and lock is still active
const isLockedByCurrentExpert =
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
// Check if lock has expired
let isLockExpired = false;
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
isLockExpired = now >= unlockTime;
}
if (isLockedByCurrentExpert && !isLockExpired) {
// This is the correct expert, and the file is locked to them, which is fine.
// They can access it even if they closed the browser and came back.
} else if (
(request.lockFile && !isLockExpired) ||
request.blameStatus === ReqBlameStatus.ReviewRequest
) {
// The file is locked by someone else, or lock expired but status hasn't updated yet
// Only block if lock is still active and not by current expert
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
throw new BadRequestException("Request is locked by another expert");
}
}
// 3. Populate the resend links if the data exists
if (request.expertResendReply) {
const populatePartyLinks = async (
partyKey: "firstParty" | "secondParty",
) => {
const partyReply = request.expertResendReply[partyKey];
if (!partyReply) return;
// Populate the voice link
if (partyReply.voice) {
const voiceDoc = await this.userSignDbService.findById(
partyReply.voice.toString(),
);
if (voiceDoc) {
partyReply.voice = buildFileLink(voiceDoc.path);
}
}
// Populate the document links
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) {
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
}
}
}
}
};
await populatePartyLinks("firstParty");
await populatePartyLinks("secondParty");
}
// 4. Populate the Signature Links from the correct reply object
// First, determine which reply object is the final, authoritative one.
const finalReply =
request.expertSubmitReplyFinal || request.expertSubmitReply;
if (finalReply) {
const populateSignatureLink = async (
commentField: "firstPartyComment" | "secondPartyComment",
) => {
const comment = finalReply[commentField];
// Check if the comment and its signDetail with a fileId exist
if (comment?.signDetail?.fileId) {
const signDoc = await this.userSignDbService.findById(
comment.signDetail.fileId.toString(),
);
if (signDoc) {
// Add a new 'fileUrl' property to the signDetail object
(comment.signDetail as any).fileUrl = buildFileLink(signDoc.path);
}
}
};
// Run the population for both parties' signatures on the correct reply object.
await populateSignatureLink("firstPartyComment");
await populateSignatureLink("secondPartyComment");
}
// 5. Format the date for display with Iran timezone (Asia/Tehran)
if (request.createdAt) {
const formattingOptions: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Tehran",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
request.createdAt = new Date(request.createdAt).toLocaleString(
"fa-IR",
formattingOptions,
);
}
// 6. Return the fully populated request object
return request;
}
/**
* V2: Get blame case details by id from blameCases collection.
* Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence.
* Access control: Only allows viewing fresh requests or requests decided by current expert.
*/
async findOneV2(
requestId: string,
actor: any,
): Promise<Record<string, unknown>> {
try {
const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc =
await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
// Tenant check
assertBlameCaseForExpertTenant(doc, actor);
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
if (
doc.type === BlameRequestType.CAR_BODY &&
!isFieldExpertOwner
) {
throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.",
);
}
// Expert-initiated: only the initiating expert
if (
doc.expertInitiated === true &&
doc.initiatedByFieldExpertId &&
String(doc.initiatedByFieldExpertId) !== actorId
) {
throw new ForbiddenException(
"Only the field expert who created this file can view and review it.",
);
}
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId
? String(decision.decidedByExpertId)
: null;
const lockedById = String((doc.workflow as any)?.lockedBy?.actorId ?? "");
const lockEnforced =
(doc.workflow as any)?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(doc);
const assignedForReviewById = String(
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
);
if (!isFieldExpertOwner) {
// Access gates — must satisfy at least one bucket
const isAvailable =
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
const isDecidedByMe = decidedByExpertId === actorId;
const isLockedByMe = lockEnforced && lockedById === actorId;
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === actorId;
if (!isAvailable && !isDecidedByMe && !isLockedByMe && !isAssignedToMe) {
// Give a specific reason if possible
if (lockEnforced && lockedById && lockedById !== actorId) {
throw new ForbiddenException(
"This request is locked by another expert.",
);
}
if (decidedByExpertId && decidedByExpertId !== actorId) {
throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.",
);
}
throw new ForbiddenException(
"You do not have permission to view this request.",
);
}
}
// Build evidence URLs
const parties = Array.isArray(doc.parties) ? doc.parties : [];
for (const party of parties as Array<{
evidence?: Record<string, unknown>;
}>) {
if (!party.evidence) continue;
const evidence = party.evidence;
if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
}
if (Array.isArray(evidence.voices)) {
const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
}
evidence.voiceUrls = voiceUrls;
}
}
// Date formatting
const createdAt = doc.createdAt
? new Date(doc.createdAt as string | number)
: new Date();
const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number)
: new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Strip heavy SandHub inquiry blob
for (const party of parties as Array<{
vehicle?: Record<string, unknown>;
}>) {
if (
party.vehicle &&
Object.prototype.hasOwnProperty.call(party.vehicle, "inquiry")
) {
delete party.vehicle.inquiry;
}
}
return doc;
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"findOneV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to get blame case details",
);
}
}
async lockRequest(requestId: string, actorDetail) {
const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) {
throw new NotFoundException("Request not found");
}
if (existing.blameStatus === ReqBlameStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because blameStatus is UserPending",
);
}
const isLocked = !!existing.lockFile;
const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!existing.unlockTime &&
Date.now() >= new Date(existing.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Request is locked by another expert");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
{
_id: requestId,
blameStatus: { $ne: ReqBlameStatus.UserPending },
},
{
$set: {
lockFile: true,
blameStatus: ReqBlameStatus.ReviewRequest,
unlockTime: fifteenMinutes,
lockTime: new Date(),
actorLocked: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
$push: {
actorsChecker: {
[ReqBlameStatus.ReviewRequest]: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
},
Date: new Date(),
},
},
},
{ new: true },
);
if (!updateResult) {
throw new BadRequestException("Request already locked or invalid status");
}
// Record expert check activity
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.CHECKED,
tenantId:
String(
updateResult?.firstPartyDetails?.firstPartyClient?.clientId ?? "",
) ||
String(
updateResult?.secondPartyDetails?.secondPartyClient?.clientId ?? "",
),
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
});
this.scheduleUnlock(updateResult);
return { _id: requestId, lock: true };
}
/**
* Assign the current field expert to a blame case for review (V2).
* Free cases are locked atomically; already-assigned-to-self is idempotent.
*/
async assignBlameCaseForReviewV2(
requestId: string,
actor: {
sub: string;
fullName?: string;
clientKey?: string;
role?: string;
},
): Promise<ExpertFileAssignResultDto> {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor);
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.blameStatus !== BlameStatus.DISAGREEMENT) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
}
if (
!isFieldExpertOwner &&
request.status !== CaseStatus.WAITING_FOR_EXPERT
) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request is not available for expert review",
});
}
if (isFieldExpertOwner && request.expert?.decision) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This request already has an expert decision",
});
}
if (
isFieldExpertOwner &&
request.status !== CaseStatus.WAITING_FOR_EXPERT
) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message:
"Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).",
});
}
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 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(
buildExpertAssignConflictForOtherReviewer(lockedById, request.workflow),
);
}
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 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": lockedByPayload,
},
$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" },
},
},
};
if (!request.workflow?.assignedForReviewBy) {
lockUpdate.$set["workflow.assignedForReviewBy"] = lockedByPayload;
}
const assignFilter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
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 (isFieldExpertOwner) {
assignFilter.initiatedByFieldExpertId = expertOid;
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
} else {
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
}
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 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 &&
this.isBlameV2WorkflowLockCurrentlyEnforced(latest) &&
otherId &&
otherId !== actor.sub
) {
throw new ConflictException(
buildExpertAssignConflictForOtherReviewer(otherId, latest?.workflow),
);
}
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "Request could not be assigned",
});
}
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",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to lock blame case",
);
}
}
/**
* V2: Request parties to resend documents/evidence (blameCases collection).
* Expert can request one or both parties to provide additional or better quality evidence.
*/
async resendRequestV2(
requestId: string,
resendDto: ResendRequestDto,
actor: any,
): Promise<{
requestId: string;
status: string;
parties: Array<{
partyId: string;
requestedItems: ResendItemType[];
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
description?: string;
}>;
}> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate request is locked by current expert
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before requesting document resend.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
if (lockedByActorId !== actorId) {
throw new ForbiddenException(
"Access denied. You are not the locked expert for this request.",
);
}
// Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt;
if (lockedAt) {
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.",
);
}
}
// Validate at least one party is specified
if (!resendDto.parties || resendDto.parties.length === 0) {
throw new BadRequestException(
"At least one party must be specified for resend request",
);
}
// Validate no existing expert decision
if (request.expert?.decision) {
throw new ForbiddenException(
"Cannot request resend after expert decision has been made.",
);
}
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.`,
);
}
const normalizedItems = normalizeResendRequestedItemsList(
party.requestedItems as string[],
);
if (normalizedItems.length === 0) {
throw new BadRequestException(
`Party ${uid}: requestedItems must include at least one valid resend item (e.g. drivingLicense, voice, description).`,
);
}
for (const item of normalizedItems) {
if (!allowedItems.has(String(item))) {
throw new BadRequestException(
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
);
}
}
}
const now = new Date();
const resendSnapshot = await this.snapshotFieldExpert(actorId);
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
const normalizedItems = normalizeResendRequestedItemsList(
party.requestedItems as string[],
);
return {
partyId: new Types.ObjectId(uid),
requestedItems: normalizedItems,
description: party.description || "",
requestedAt: now,
completed: false,
uploadedDocuments: {},
};
});
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_DOCUMENT_RESEND",
"workflow.nextStep": "WAITING_FOR_GUILT_DECISION",
"expert.resend": {
parties: partyResendRequests,
requestedAt: now,
requestedByExpertId: new Types.ObjectId(actorId),
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with resend request",
);
}
const resendTenantId =
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? "");
await this.recordBlameExpertActivity({
expertId: String(actorId),
requestId: String(requestId),
eventType: ExpertFileActivityType.UNCHECKED,
tenantId: resendTenantId,
idempotencyKey: `blame:${requestId}:unchecked:resend:${actorId}`,
});
await this.recordBlameExpertActivity({
expertId: String(actorId),
requestId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
tenantId: resendTenantId,
idempotencyKey: `blame:${requestId}:handled:resend:${actorId}`,
});
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
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?: readonly string[];
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,
),
});
}
}
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;
this.logger.error(
"resendRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to request document resend",
);
}
}
/**
* 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,
reply: SubmitReplyDto,
actor: any,
): Promise<{ requestId: string; status: string }> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
if (actor.role !== RoleEnum.FIELD_EXPERT) {
requireActorClientKey(actor);
}
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
const expertProfileSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)),
description: reply.description,
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
...(expertProfileSnapshot && { expertProfileSnapshot }),
fields: {
accidentWay: {
id: reply.fields.accidentWay.id,
label: reply.fields.accidentWay.label,
},
accidentReason: {
id: reply.fields.accidentReason.id,
label: reply.fields.accidentReason.label,
fanavaran: reply.fields.accidentReason.fanavaran,
},
accidentType: {
id: reply.fields.accidentType.id,
label: reply.fields.accidentType.label,
},
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_SIGNATURES",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.WAITING_FOR_SIGNATURES,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
await this.recordBlameExpertActivity({
expertId: String(actorId),
requestId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
tenantId:
String(request.parties?.[0]?.person?.clientId ?? "") ||
String(request.parties?.[1]?.person?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:handled:${actorId}`,
});
// 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() || "کارشناس";
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 linkRole = partyRole === PartyRole.SECOND ? "SECOND" : "FIRST";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
expertLastName,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
linkRole,
),
});
}
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"replyRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to submit expert reply",
);
}
}
async submitInPerson(requestId: string, actorId: string) {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
const inPersonSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: null,
description: "حضوری مراجعه شود.",
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
...(inPersonSnapshot && { expertProfileSnapshot: inPersonSnapshot }),
fields: {
accidentWay: null,
accidentReason: null,
accidentType: null,
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "COMPLETED",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.STOPPED,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.expiredAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"submitInPerson failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to submit expert reply",
);
}
}
private async recordBlameExpertActivity(args: {
expertId: string;
requestId: string;
eventType: ExpertFileActivityType;
tenantId?: string;
idempotencyKey?: string;
}) {
const tenantId =
args.tenantId && Types.ObjectId.isValid(args.tenantId)
? args.tenantId
: undefined;
if (
!Types.ObjectId.isValid(args.expertId) ||
!Types.ObjectId.isValid(args.requestId) ||
!tenantId
) {
return;
}
await this.expertFileActivityDbService.recordEvent({
expertId: args.expertId,
tenantId,
fileId: args.requestId,
fileType: ExpertFileKind.BLAME,
eventType: args.eventType,
idempotencyKey: args.idempotencyKey,
});
}
async replyRequest(requestId: string, reply: SubmitReplyDto, userId: string) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (String(request.actorLocked?.actorId) !== userId) {
throw new ForbiddenException(
"Access denied to this request. You are not the locked expert.",
);
}
// Check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
throw new ForbiddenException("Your lock time has expired.");
}
} else if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired.");
}
if (!request.lockFile) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const isObjection = !!request.expertResendReply;
const replyField = isObjection
? "expertSubmitReplyFinal"
: "expertSubmitReply";
if (!isObjection && request.expertSubmitReply) {
throw new ForbiddenException(
"This request already has an initial expert reply.",
);
}
if (isObjection && request.expertSubmitReplyFinal) {
throw new ForbiddenException(
"This request already has a final expert reply.",
);
}
const expertProfileSnapshot = await this.snapshotFieldExpert(userId);
const newReplyObject = {
description: reply.description,
submitTime: new Date(),
guiltyUserId: reply.guiltyUserId,
fields: {
accidentWay: {
id: reply.fields.accidentWay.id,
label: reply.fields.accidentWay.label,
},
accidentReason: {
id: reply.fields.accidentReason.id,
label: reply.fields.accidentReason.label,
fanavaran: reply.fields.accidentReason.fanavaran,
},
accidentType: {
id: reply.fields.accidentType.id,
label: reply.fields.accidentType.label,
},
},
firstPartyComment: request.expertSubmitReply?.firstPartyComment || null,
secondPartyComment: request.expertSubmitReply?.secondPartyComment || null,
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
const updatePayload: any = {
$set: {
lockFile: false,
blameStatus: ReqBlameStatus.CheckedRequest,
[replyField]: newReplyObject,
},
$push: {
actorsChecker: {
[ReqBlameStatus.CheckedRequest]: request.actorLocked,
Date: new Date(),
},
},
};
if (isObjection) {
updatePayload.$set.expertSubmitReply = newReplyObject;
}
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
return {
requestId: request._id,
blameStatus: ReqBlameStatus.CheckedRequest,
};
} catch (error) {
this.logger.error("Failed to submit expert reply:", error);
throw new Error("Failed to submit expert reply");
}
}
async sendAgainRequest(
requestId: string,
resend: any,
userId: string,
req: any,
) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (String(request.actorLocked?.actorId) !== userId) {
throw new ForbiddenException("Access denied to this request");
}
if (request.expertSubmitReply) {
throw new ForbiddenException("Request already has an expert reply");
}
if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired or was not set");
}
const partyType = req.route.path.split("/")[4];
const resendSnapshot = await this.snapshotFieldExpert(userId);
switch (partyType) {
case "first": {
if (request.expertResendReply?.firstParty) {
throw new ForbiddenException(
"Request has an expert resend reply for the first party",
);
}
const { firstPartyId, firstPartyDescription } = resend;
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
lockFile: false,
blameStatus: ReqBlameStatus.UserPending,
"expertResendReply.firstParty.firstPartyId": firstPartyId,
"expertResendReply.firstParty.firstPartyDescription":
firstPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: {
actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked,
Date: new Date(),
},
},
},
);
} catch (error) {
this.logger.error("Failed to update for first party:", error);
throw error;
}
return {
requestId: request._id,
blameStatus: ReqBlameStatus.UserPending,
};
}
case "second": {
if (request.expertResendReply?.secondParty) {
throw new ForbiddenException(
"Request has an expert resend reply for the second party",
);
}
const { secondPartyId, secondPartyDescription } = resend;
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
lockFile: false,
blameStatus: ReqBlameStatus.UserPending,
"expertResendReply.secondParty.secondPartyId": secondPartyId,
"expertResendReply.secondParty.secondPartyDescription":
secondPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: {
actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked,
Date: new Date(),
},
},
},
);
} catch (error) {
this.logger.error("Failed to update for second party:", error);
throw error;
}
// TODO notification for user parties
// TODO send SMS notification
// TODO send URI For USER
return {
requestId: request._id,
blameStatus: ReqBlameStatus.UserPending,
};
}
default:
throw new BadRequestException(
`Invalid party type in URL: ${partyType}`,
);
}
}
/// VIDEO SERVICE && VOICE SERVICE
// TODO add video service to Object Storage
async streamVideo(requestId): Promise<string> {
const request = await this.requestManagementDbService.findOne(requestId);
const video_path = await this.blameVideoDbService.findOne(
String(request.firstPartyDetails.firstPartyFile.firstPartyVideoId),
);
return buildFileLink(video_path.path);
}
async streamVoice(requestId, voiceId) {
try {
const voice = await this.blameVoiceDbService.findOne(voiceId);
if (!voice) throw new NotFoundException("not found voice");
if (String(voice.requestId) === requestId) {
return buildFileLink(voice.path);
} else {
throw new ForbiddenException(
"Can Not Access To This Voice Because Voice is Not Assign to RequestID",
);
}
} catch (er) {
if (er) throw new NotFoundException("voice not found ", er);
}
}
async getAccidentField() {
try {
const ac_reason = await readFile(
"src/static/ACCIDENT_REASON.json",
"utf-8",
);
const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8");
const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8");
return {
accidentReason: JSON.parse(ac_reason),
accidentType: JSON.parse(ac_type),
accidentWay: JSON.parse(ac_way),
};
} catch (err) {
this.logger.error(err);
}
}
async inPersonVisit(requestId: string, actorDetail: any) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Blame not found");
}
const visitSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
blameStatus: ReqBlameStatus.InPersonVisit,
...(visitSnapshot && {
fieldExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
},
);
await this.recordBlameExpertActivity({
expertId: String(actorDetail.sub),
requestId: String(requestId),
eventType: ExpertFileActivityType.HANDLED,
tenantId:
String(request.firstPartyDetails?.firstPartyClient?.clientId ?? "") ||
String(request.secondPartyDetails?.secondPartyClient?.clientId ?? ""),
idempotencyKey: `blame:${requestId}:handled:inperson:${actorDetail.sub}`,
});
return updated;
}
}