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, isInListDateRange, parseListDateRange } 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 { UnifiedFileStatusReportDto, UnifiedFileStatusReportQueryDto, } from "src/common/dto/unified-file-status-report.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> { 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[], claimByPublicId: Map, unifiedStatus?: string, ): Record[] { 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, query: UnifiedFileStatusReportQueryDto = {}, ): Promise { let rows = await this.collectBlamePortfolioDocs(actor); const { fromDate, toDate } = parseListDateRange(query.from, query.to); rows = rows.filter((doc) => isInListDateRange( (doc as { createdAt?: Date }).createdAt, fromDate, toDate, ), ); if (query.fileType) { rows = rows.filter( (doc) => String((doc as { type?: string }).type ?? "") === query.fileType, ); } 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[]> { if (actor.role === RoleEnum.FIELD_EXPERT) { return (await this.blameRequestDbService.find( { expertInitiated: true, initiatedByFieldExpertId: new Types.ObjectId(actor.sub), }, { lean: true }, )) as Record[]; } 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[] = [ { 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[]; const seen = new Set(); const out: Record[] = []; 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 { // 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> { 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[] = [ { 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(); for (const doc of rows as Record[]) { 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 { 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[]).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(); for (const doc of visibleCases) { const w = doc.workflow as Record | 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; 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 { const expertId = actor.sub; const expertOid = new Types.ObjectId(expertId); const visibleCases = (await this.blameRequestDbService.find( { expertInitiated: true, initiatedByFieldExpertId: expertOid, }, { lean: true }, )) as Record[]; const staleIds = new Set(); for (const doc of visibleCases) { const w = doc.workflow as Record | 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; 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[], query: ListQueryV2Dto, ): Promise { 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, }); }, fileType: (doc) => String((doc as { type?: string }).type ?? "") || undefined, 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, claimByPublicId?: Map, ): 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; 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 { 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 = { _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 = { "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> { 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; }>) { 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; }>) { 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 { 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; $push: Record; } = { $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 = { _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; 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(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(); 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 { 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; } }