Files
yara724api/src/expert-blame/expert-blame.service.ts

1335 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ForbiddenException,
HttpException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
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<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);
}
/**
* 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<AllRequestDtoRsV2> {
try {
const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find(
{
blameStatus: BlameStatus.DISAGREEMENT,
},
{ lean: true },
);
// Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert
// 3. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
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<string, unknown>,
): 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<string, unknown>;
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 nonCAR_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, actorId: string): Promise<Record<string, unknown>> {
try {
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
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<string, unknown>;
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");
}
// 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,
actorId: string,
): Promise<{ requestId: string; status: string }> {
try {
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// 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,
actorId: string,
): Promise<{ requestId: string; status: string }> {
try {
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() + 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<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;
}
}