1
0
forked from Yara724/api
Files
yara724-api/src/expert-blame/expert-blame.service.ts
SepehrYahyaee 8db56d38be YARA-725
2026-01-26 11:50:34 +03:30

756 lines
24 KiB
TypeScript

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<AllRequestDtoRs> {
// 1. Fetch all potentially relevant requests from the database.
// Exclude CAR_BODY type requests as they are automatically handled and don't need expert review
const allRequests = await this.requestManagementDbService.findAll({
"firstPartyDetails.firstPartyPlate": { $ne: null },
"secondPartyDetails.secondPartyPlate": { $ne: null },
blameStatus: {
$in: [
ReqBlameStatus.UnChecked,
ReqBlameStatus.CloseRequest,
ReqBlameStatus.CheckAgain,
ReqBlameStatus.ReviewRequest,
],
},
// Both parties must have submitted their initial forms
"firstPartyDetails.firstPartyInitialForm": { $exists: true },
"secondPartyDetails.secondPartyInitialForm": { $exists: true },
type: { $ne: "CAR_BODY" }, // Exclude CAR_BODY type requests
});
// 2. Filter requests that need expert review based on initial form logic
// Expert is needed when there's a conflict (both claim damaged, both claim guilty, etc.)
// Expert is NOT needed when one says imDamaged and the other says imGuilty (auto-resolved)
const requestsNeedingExpert = [];
for (const request of allRequests) {
const firstPartyForm = request.firstPartyDetails?.firstPartyInitialForm;
const secondPartyForm = request.secondPartyDetails?.secondPartyInitialForm;
if (!firstPartyForm || !secondPartyForm) {
continue; // Skip if forms are not complete
}
// Check if this can be auto-resolved (one says damaged, other says guilty)
const canAutoResolve =
(firstPartyForm.imDamaged && secondPartyForm.imGuilty) ||
(secondPartyForm.imDamaged && firstPartyForm.imGuilty);
// If it can be auto-resolved, skip it (no expert needed)
if (canAutoResolve) {
continue;
}
// Otherwise, expert is needed (both damaged, both guilty, or other conflicts)
requestsNeedingExpert.push(request);
}
// 3. Filter the requests in memory based on the expert's specific access rights.
const visibleRequests = [];
for (const request of requestsNeedingExpert) {
// For expert-initiated files, only show to the initiating expert
if (request.expertInitiated && request.initiatedBy) {
if (String(request.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated files are always visible to the initiating expert
visibleRequests.push(request);
continue;
}
// For normal files, use existing client-based filtering
const firstPartyClientId =
request.firstPartyDetails?.firstPartyClient?.clientId?.toString();
const secondPartyClientId =
request.secondPartyDetails?.secondPartyClient?.clientId?.toString();
const partyClientIds = [firstPartyClientId, secondPartyClientId]
.filter(Boolean)
.map((id) => new Types.ObjectId(id));
if (partyClientIds.length === 0) {
continue;
}
let clientQuery: any = { _id: { $in: partyClientIds } };
if (actor.userType === UserType.LEGAL) {
clientQuery = {
$and: [
{ _id: { $in: partyClientIds } },
{ _id: new Types.ObjectId(actor.clientKey) },
],
};
}
const client = await this.clientDbService.findOne(clientQuery);
if (!client) {
continue;
}
const isExpertTypeMatch = client.useExpertMode === actor.userType;
if (!isExpertTypeMatch) {
continue;
}
if (request.blameStatus === ReqBlameStatus.CheckAgain) {
if (String(request.actorLocked?.actorId) === actor.sub) {
visibleRequests.push(request);
}
} else {
visibleRequests.push(request);
}
}
return new AllRequestDtoRs(visibleRequests);
}
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<string> {
const request = await this.requestManagementDbService.findOne(requestId);
const video_path = await this.blameVideoDbService.findOne(
String(request.firstPartyDetails.firstPartyFile.firstPartyVideoId),
);
return buildFileLink(video_path.path);
}
async streamVoice(requestId, voiceId) {
try {
const voice = await this.blameVoiceDbService.findOne(voiceId);
if (!voice) throw new NotFoundException("not found voice");
if (String(voice.requestId) === requestId) {
return buildFileLink(voice.path);
} else {
throw new ForbiddenException(
"Can Not Access To This Voice Because Voice is Not Assign to RequestID",
);
}
} catch (er) {
if (er) throw new NotFoundException("voice not found ", er);
}
}
async getAccidentField() {
try {
const ac_reason = await readFile(
"src/static/ACCIDENT_REASON.json",
"utf-8",
);
const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8");
const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8");
return {
accidentReason: JSON.parse(ac_reason),
accidentType: JSON.parse(ac_type),
accidentWay: JSON.parse(ac_way),
};
} catch (err) {
this.logger.error(err);
}
}
async inPersonVisit(requestId: string, actorDetail: any) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Blame not found");
}
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
blameStatus: ReqBlameStatus.InPersonVisit,
},
);
await this.expertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
return updated;
}
}