import { BadRequestException, ForbiddenException, HttpException, Injectable, InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; import { assertBlameCaseForExpertTenant, blameCaseAccessibleToExpert, requireActorClientKey, } from "src/helpers/tenant-scope"; import { blameCaseStatusToReportBucket, initialBlameExpertReportBuckets, } from "src/helpers/expert-panel-status-report"; 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 } 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"; 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, ) { } /** 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 for this expert’s tenant, grouped for dashboard: * `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged. */ async getStatusReportBucketsV2(actor: any): Promise> { requireActorClientKey(actor); const rows = await this.blameRequestDbService.find({}, { lean: true }); const buckets = initialBlameExpertReportBuckets(); for (const doc of rows as Record[]) { if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue; const st = String(doc.status ?? ""); const key = blameCaseStatusToReportBucket(st); buckets.all++; buckets[key] = (buckets[key] ?? 0) + 1; } return buckets; } /** Tenant + expert-initiated visibility (same as list, without queue filters). */ private blameDocIncludedInExpertTenantReport( doc: Record, actor: { sub: string; clientKey?: string }, ): boolean { if (!blameCaseAccessibleToExpert(doc, actor)) { return false; } if (doc.expertInitiated && doc.initiatedByFieldExpertId) { return String(doc.initiatedByFieldExpertId) === String(actor.sub); } return true; } async findAllV2(actor: any): Promise { try { requireActorClientKey(actor); const expertId = actor.sub; // Fetch all DISAGREEMENT cases const allCases = await this.blameRequestDbService.find( { blameStatus: BlameStatus.DISAGREEMENT, }, { lean: true }, ); // Filter to show only: // 1. Same insurance tenant (party clientId or expert-initiated by this actor) // 2. Fresh requests (WAITING_FOR_EXPERT and no decision) // 3. Requests decided by current expert // 4. Expert-initiated: only the initiating field expert sees them const visibleCases = (allCases as Record[]).filter((doc) => { if (!blameCaseAccessibleToExpert(doc, actor)) { return false; } const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; if (expertInitiated && initiatedByFieldExpertId) { if (String(initiatedByFieldExpertId) !== expertId) { return false; // Only the initiating field expert can see this file } return true; // Initiating expert can see their expert-initiated file } const status = doc.status as string; const decision = doc.expert as any; const decidedByExpertId = decision?.decision?.decidedByExpertId; const hasDecision = !!decision?.decision; // Fresh request (no decision yet) if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) { return true; } // Request decided by current expert if (decidedByExpertId && String(decidedByExpertId) === expertId) { return true; } // Locked by current expert but no decision yet const lockedBy = decision?.resend?.requestedByExpertId || (doc.workflow as any)?.lockedBy?.actorId; if (status === CaseStatus.WAITING_FOR_EXPERT && lockedBy && String(lockedBy) === expertId) { return true; } return false; }); const staleIds = new Set(); for (const doc of visibleCases) { const w = doc.workflow as Record | undefined; if (!w?.locked) continue; const la = w.lockedAt; if (!la) { staleIds.add(String(doc._id)); continue; } if ( Date.now() >= new Date(la as string | Date).getTime() + this.blameV2LockTtlMs ) { staleIds.add(String(doc._id)); } } await Promise.all( [...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)), ); for (const doc of visibleCases) { if (!staleIds.has(String(doc._id))) continue; const w = doc.workflow as Record; if (w) { w.locked = false; delete w.lockedAt; delete w.lockedBy; } } const items: AllRequestDtoV2[] = visibleCases.map( (doc) => this.mapBlameRequestToListItemV2(doc), ); return new AllRequestDtoRsV2(items); } 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", ); } } private mapBlameRequestToListItemV2( doc: Record, ): 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"); return { requestId: String(doc._id), status: String(doc.status ?? ""), userComment: null, requestCode: String(doc.publicId ?? ""), date, time, updatedAtDate, updatedAtTime, lockFile: Boolean(workflow.locked), lockTime: workflow.lockedAt ? new Date(workflow.lockedAt as string).toISOString() : null, type: String(doc.type ?? "THIRD_PARTY"), blameStatus: String(doc.blameStatus ?? ""), partiesInitialForms: { firstParty: statementToFormKey(firstParty?.statement) ?? "", secondParty: statementToFormKey(secondParty?.statement) ?? "", }, partiesVehicles: { firstPartyVehicle: firstParty?.vehicle?.name || firstParty?.vehicle?.inquiry?.mapped?.MapTypNam || firstParty?.vehicle?.inquiry?.mapped?.CarName || "", secondPartyVehicle: secondParty?.vehicle?.name || secondParty?.vehicle?.inquiry?.mapped?.MapTypNam || secondParty?.vehicle?.inquiry?.mapped?.CarName || "", } }; } /** * 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; 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 (lockedAt) { filter["workflow.lockedAt"] = lockedAt; } else if (lockedById) { filter["workflow.lockedBy.actorId"] = new Types.ObjectId( String(lockedById), ); } const cleared = await this.blameRequestDbService.findOneAndUpdate( filter as any, { $set: { "workflow.locked": false }, $unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" }, }, { new: false }, ); if (!cleared) { return; } if (!request.expert?.decision && lockedById) { const rid = new Types.ObjectId(requestId).toString(); try { await this.expertDbService.findOneAndUpdate( { _id: new Types.ObjectId(String(lockedById)) }, { $inc: { "requestStats.totalChecked": -1 }, $pull: { countedRequests: rid } as any, }, ); this.logger.warn( `Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`, ); } catch (e) { this.logger.warn( `Expert stat rollback failed after blame lock expiry ${requestId}`, e, ); } } } public unlockApi(request, timer) { return setTimeout(async () => { try { 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.expertDbService.findOneAndUpdate( { _id: new Types.ObjectId(r.actorLocked.actorId) }, { $inc: { "requestStats.totalChecked": -1 }, $pull: { countedRequests: r._id.toString() }, }, ); 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.expertDbService.findOneAndUpdate( { _id: new Types.ObjectId(current.actorLocked.actorId) }, { $inc: { "requestStats.totalChecked": -1 }, $pull: { countedRequests: current._id.toString() }, }, ); 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 { requireActorClientKey(actor); const actorId = actor.sub; const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); if (!doc) { throw new NotFoundException("Request not found"); } assertBlameCaseForExpertTenant(doc, actor); const type = doc.type as string; if (type === BlameRequestType.CAR_BODY) { throw new ForbiddenException( "CAR_BODY type requests are automatically handled and do not require expert review.", ); } // Access control const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; if (expertInitiated && initiatedByFieldExpertId && String(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; if (decidedByExpertId && String(decidedByExpertId) !== actorId) { throw new ForbiddenException( "You do not have permission to view this request. It has been handled by another expert.", ); } const parties = Array.isArray(doc.parties) ? doc.parties : []; const typedParties = parties as Array<{ vehicle?: { inquiry?: { mapped?: any; }; }; evidence?: { videoId?: string | number; voices?: (string | number)[]; videoUrl?: string; voiceUrls?: string[]; }; }>; // Evidence (videos + voices) for (const party of typedParties) { if (!party.evidence) continue; const evidence = party.evidence as Record; if (evidence.videoId) { const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId)); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); } if (evidence.voices && 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}`; // Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields) for (const party of typedParties) { const veh = party?.vehicle as Record | undefined; if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) { delete veh.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"); } // Update expert stats atomically (use findOneAndUpdate with conditions) await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked"); this.scheduleUnlock(updateResult); return { _id: requestId, lock: true }; } /** * V2: Lock blame case for 15 minutes (blameCases collection). * Sets workflow.locked, workflow.lockedBy, workflow.lockedAt. * Checks if existing lock is expired and allows re-locking. */ async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> { try { const now = new Date(); await this.expireBlameCaseWorkflowLockV2IfStale(requestId); // First, check the current state const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } assertBlameCaseForExpertTenant(request, actorDetail); // Validate request is available for expert review if ( request.status !== CaseStatus.WAITING_FOR_EXPERT || request.blameStatus !== BlameStatus.DISAGREEMENT ) { throw new BadRequestException( "Request is not available for expert review", ); } // Expert-initiated: only the initiating field expert can lock and review if ( request.expertInitiated && request.initiatedByFieldExpertId && String(request.initiatedByFieldExpertId) !== actorDetail.sub ) { throw new ForbiddenException( "Only the field expert who created this file can lock and review it.", ); } // Check if locked and not expired if (request.workflow?.locked) { const lockedAt = request.workflow.lockedAt; if (lockedAt) { const lockExpiryTime = new Date(lockedAt).getTime() + this.blameV2LockTtlMs; if (Date.now() < lockExpiryTime) { // Lock is still valid const lockedByActorId = String( request.workflow.lockedBy?.actorId ?? "", ); if (lockedByActorId === actorDetail.sub) { throw new BadRequestException( "You have already locked this request", ); } else { throw new BadRequestException( "Request is currently locked by another expert", ); } } // Lock expired, allow re-locking (continue below) } } const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub); // Lock the request (either unlocked or expired lock) const updateResult = await this.blameRequestDbService.findByIdAndUpdate( requestId, { $set: { "workflow.locked": true, "workflow.lockedAt": now, "workflow.lockedBy": { actorId: new Types.ObjectId(actorDetail.sub), actorName: actorDetail.fullName || "Unknown Expert", actorRole: "expert", ...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }), }, }, }, ); if (!updateResult) { throw new InternalServerErrorException("Failed to lock the request"); } // Update expert stats (reusing existing helper) await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked"); if (request.type === BlameRequestType.THIRD_PARTY) { const phones = (request.parties || []) .map((p: any) => p?.person?.phoneNumber) .filter((p: unknown): p is string => typeof p === "string" && p.length > 0); const expertLastName = actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; for (const phone of phones) { await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice( { receptor: phone, publicId: request.publicId, expertLastName, }, ); } } return { _id: requestId, lock: true }; } catch (error) { 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); 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.`, ); } for (const item of party.requestedItems || []) { if (!allowedItems.has(String(item))) { throw new BadRequestException( `Invalid requestedItems value: ${item}. Use ResendItemType values.`, ); } } } const now = new Date(); const resendSnapshot = await this.snapshotFieldExpert(actorId); const partyResendRequests = resendDto.parties.map((party) => { const uid = String(party.partyId); return { partyId: new Types.ObjectId(uid), requestedItems: party.requestedItems, description: party.description || "", requestedAt: now, completed: false, uploadedDocuments: {}, }; }); const updatePayload = { $set: { "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.lockedBy": "", }, }; const updateResult = await this.blameRequestDbService.findByIdAndUpdate( requestId, updatePayload, ); if (!updateResult) { throw new InternalServerErrorException( "Failed to update request with resend request", ); } await this.requestManagementService.applyLinkedClaimsBlameResendStarted( requestId, ); 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); } } for (const p of partyResendRequests) { const uid = String((p as any).partyId); const phone = partyPhoneByUserId.get(uid); if (!phone) continue; const role = String(request.parties?.find((x: any) => String(x?.person?.userId) === uid)?.role) === "SECOND" ? "SECOND" : "FIRST"; await this.smsOrchestrationService.sendResendDocumentsNotice({ receptor: phone, fileKind: "blame", publicId: request.publicId, link: this.smsOrchestrationService.buildBlamePartyLink(requestIdToken, role), }); } return { 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); 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.lockedBy": "", }, }; const updateResult = await this.blameRequestDbService.findByIdAndUpdate( requestId, updatePayload, ); if (!updateResult) { throw new InternalServerErrorException( "Failed to update request with expert reply", ); } if (request.type === BlameRequestType.THIRD_PARTY) { const requestIdToken = String(request._id); const expertLastName = actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس"; for (const p of request.parties || []) { const phone = p?.person?.phoneNumber; if (!phone || typeof phone !== "string") continue; const role = String(p?.role) === "SECOND" ? "SECOND" : "FIRST"; await this.smsOrchestrationService.sendSignatureReviewNotice({ receptor: phone, fileKind: "blame", publicId: request.publicId, expertLastName, link: this.smsOrchestrationService.buildBlamePartyLink( requestIdToken, role, ), }); } } 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.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 updateDamageExpertStats( expertId: string, requestId: string, type: "checked" | "handled", ) { if (!expertId || !requestId || !["checked", "handled"].includes(type)) { console.warn("Invalid expertId, requestId, or type"); return; } const expert = await this.expertDbService.findOne({ _id: new Types.ObjectId(expertId), }); if (!expert) { console.warn("Expert not found:", expertId); return; } const requestIdStr = new Types.ObjectId(requestId).toString(); const countedRequestIds = expert.countedRequests?.map((id) => id.toString()) || []; if (type === "checked" && countedRequestIds.includes(requestIdStr)) { console.log( `Request ${requestIdStr} already checked for expert ${expertId}`, ); return; } const update: any = { $inc: {}, $push: {} }; if (type === "checked") { update.$inc["requestStats.totalChecked"] = 1; update.$push["countedRequests"] = requestIdStr; } else if (type === "handled") { update.$inc["requestStats.totalHandled"] = 1; if (countedRequestIds.includes(requestIdStr)) { update.$inc["requestStats.totalChecked"] = -1; } if (!countedRequestIds.includes(requestIdStr)) { update.$push["countedRequests"] = requestIdStr; } else { delete update.$push; } } const updateResult = await this.expertDbService.findOneAndUpdate( { _id: new Types.ObjectId(expertId) }, update, ); if (!updateResult) { console.warn("Failed to update expert stats for:", expertId); } else { console.log(`Expert stats updated (${type}) for expert:`, expertId); } } 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.expertDbService.updateStats( actorDetail.sub, "handled", requestId, ); return updated; } }