forked from Yara724/api
2452 lines
81 KiB
TypeScript
2452 lines
81 KiB
TypeScript
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 {
|
||
countUnifiedFileStatuses,
|
||
getUnifiedFileStatusCatalog,
|
||
resolveUnifiedFileStatus,
|
||
} from "src/helpers/unified-file-status";
|
||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||
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,
|
||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||
) {}
|
||
|
||
private async loadClaimsByPublicIds(
|
||
publicIds: string[],
|
||
): Promise<Map<string, { status?: string }>> {
|
||
const unique = [...new Set(publicIds.filter(Boolean))];
|
||
if (unique.length === 0) return new Map();
|
||
const claims = (await this.claimCaseDbService.find(
|
||
{ publicId: { $in: unique } },
|
||
{ lean: true, select: "publicId status" },
|
||
)) as Array<{ publicId?: string; status?: string }>;
|
||
return new Map(
|
||
claims.map((c) => [String(c.publicId), { status: c.status }]),
|
||
);
|
||
}
|
||
|
||
private filterBlameDocsByUnifiedStatus(
|
||
docs: Record<string, unknown>[],
|
||
claimByPublicId: Map<string, { status?: string }>,
|
||
unifiedStatus?: string,
|
||
): Record<string, unknown>[] {
|
||
if (!unifiedStatus) return docs;
|
||
return docs.filter((doc) => {
|
||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||
const claim = claimByPublicId.get(publicId);
|
||
return (
|
||
resolveUnifiedFileStatus({
|
||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||
claimStatus: claim?.status,
|
||
}) === unifiedStatus
|
||
);
|
||
});
|
||
}
|
||
|
||
async getUnifiedFileStatusReportV2(actor: any) {
|
||
const rows = await this.collectBlamePortfolioDocs(actor);
|
||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||
rows.map((d) => String((d as { publicId?: string }).publicId ?? "")),
|
||
);
|
||
return {
|
||
statuses: getUnifiedFileStatusCatalog(),
|
||
counts: countUnifiedFileStatuses(
|
||
rows.map((doc) => {
|
||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||
const claim = claimByPublicId.get(publicId);
|
||
return {
|
||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||
claimStatus: claim?.status,
|
||
};
|
||
}),
|
||
),
|
||
};
|
||
}
|
||
|
||
private async collectBlamePortfolioDocs(
|
||
actor: any,
|
||
): Promise<Record<string, unknown>[]> {
|
||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||
return (await this.blameRequestDbService.find(
|
||
{
|
||
expertInitiated: true,
|
||
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
|
||
},
|
||
{ lean: true },
|
||
)) as Record<string, unknown>[];
|
||
}
|
||
|
||
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 },
|
||
)) as Record<string, unknown>[];
|
||
const seen = new Set<string>();
|
||
const out: Record<string, unknown>[] = [];
|
||
for (const doc of rows) {
|
||
const id = String(doc._id ?? "");
|
||
if (seen.has(id)) continue;
|
||
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||
seen.add(id);
|
||
out.push(doc);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 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 expert’s 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 pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||
return pagedResult;
|
||
} 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 pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||
return pagedResult;
|
||
}
|
||
|
||
private async paginateBlameListV2(
|
||
visibleCases: Record<string, unknown>[],
|
||
query: ListQueryV2Dto,
|
||
): Promise<AllRequestDtoRsV2> {
|
||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||
visibleCases.map((d) =>
|
||
String((d as { publicId?: string }).publicId ?? ""),
|
||
),
|
||
);
|
||
const filtered = this.filterBlameDocsByUnifiedStatus(
|
||
visibleCases,
|
||
claimByPublicId,
|
||
query.unifiedStatus,
|
||
);
|
||
|
||
const paged = applyListQueryV2(
|
||
filtered,
|
||
{
|
||
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) => {
|
||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||
const claim = claimByPublicId.get(publicId);
|
||
return resolveUnifiedFileStatus({
|
||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||
claimStatus: claim?.status,
|
||
});
|
||
},
|
||
searchExtras: (doc) => {
|
||
const d = doc as {
|
||
_id?: unknown;
|
||
blameStatus?: string;
|
||
type?: string;
|
||
publicId?: string;
|
||
status?: string;
|
||
};
|
||
const publicId = String(d.publicId ?? "");
|
||
const claim = claimByPublicId.get(publicId);
|
||
const unified = resolveUnifiedFileStatus({
|
||
blameStatus: String(d.status ?? ""),
|
||
claimStatus: claim?.status,
|
||
});
|
||
return [
|
||
String(d._id ?? ""),
|
||
d.blameStatus,
|
||
d.type,
|
||
unified,
|
||
claim?.status,
|
||
].filter(Boolean) as string[];
|
||
},
|
||
},
|
||
query,
|
||
);
|
||
|
||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||
this.mapBlameRequestToListItemV2(doc, claimByPublicId),
|
||
);
|
||
|
||
return new AllRequestDtoRsV2({
|
||
data: items,
|
||
total: paged.total,
|
||
page: paged.page,
|
||
limit: paged.limit,
|
||
totalPages: paged.totalPages,
|
||
});
|
||
}
|
||
|
||
private mapBlameRequestToListItemV2(
|
||
doc: Record<string, unknown>,
|
||
claimByPublicId?: Map<string, { status?: string }>,
|
||
): 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");
|
||
const publicId = String(doc.publicId ?? "");
|
||
const linkedClaim = claimByPublicId?.get(publicId);
|
||
const unifiedFileStatus = resolveUnifiedFileStatus({
|
||
blameStatus: String(doc.status ?? ""),
|
||
claimStatus: linkedClaim?.status,
|
||
});
|
||
|
||
return {
|
||
requestId: String(doc._id),
|
||
status: String(doc.status ?? ""),
|
||
unifiedFileStatus,
|
||
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 non–CAR_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;
|
||
}
|
||
}
|