import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, } from "@nestjs/common"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { AllRequestDtoRs } 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 { buildFileLink } from "src/helpers/urlCreator"; 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; } @Injectable() export class ExpertBlameService { private readonly logger = new Logger(ExpertBlameService.name); constructor( private readonly requestManagementDbService: RequestManagementDbService, 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); } 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; } 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 }; } 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; } }