import { BadRequestException, ForbiddenException, HttpException, Injectable, InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; import { assertBlameCaseForExpertTenant, blameCaseAccessibleToExpert, requireActorClientKey, } from "src/helpers/tenant-scope"; 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 { 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"; 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); 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, ) {} 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. */ async findAllV2(actor: any): Promise { try { requireActorClientKey(actor); const expertId = actor.sub; const allCases = await this.blameRequestDbService.find({}, { 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 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; 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) ?? "", }, }; } 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: Expert-initiated files only visible to the initiating field expert 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.", ); } // Allow if: // 1. No decision yet (fresh request) // 2. Decision made by current expert 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 = (doc.parties ?? []) as Array<{ evidence?: { videoId?: string; voices?: string[] }; [key: string]: unknown; }>; for (const party of parties) { 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; } } const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; // Exclude mapped inquiry vehicle for both parties from response for (const party of parties) { delete party.vehicle; } 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 fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const updateResult = await this.requestManagementDbService.findOneAndUpdate( { _id: requestId, lockFile: false, 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), }, }, $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(); const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000); // 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() + 15 * 60 * 1000; 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) } } // 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", }, }, }, ); if (!updateResult) { throw new InternalServerErrorException("Failed to lock the request"); } // Update expert stats (reusing existing helper) await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked"); 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 }> { try { 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() + 15 * 60 * 1000; 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 now = new Date(); // Build resend requests for parties const partyResendRequests = resendDto.parties.map((party) => ({ partyId: new Types.ObjectId(String(party.partyId)), requestedItems: party.requestedItems, description: party.description || "", requestedAt: now, completed: false, })); const 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), }, status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, }, $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 resend request", ); } // TODO: Send notifications to parties (SMS/Push) about required documents return { requestId: String(request._id), status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND, }; } 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. */ async replyRequestV2( requestId: string, reply: SubmitReplyDto, actor: any, ): Promise<{ requestId: string; status: string }> { try { 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() + 15 * 60 * 1000; 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(); // 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), 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", ); } 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", ); } } 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 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, }; 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]; 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, $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, $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 updated = await this.requestManagementDbService.findAndUpdate( { _id: new Types.ObjectId(requestId) }, { blameStatus: ReqBlameStatus.InPersonVisit, }, ); await this.expertDbService.updateStats( actorDetail.sub, "handled", requestId, ); return updated; } }