1
0
forked from Yara724/api
Files
yara724-api/src/expert-blame/expert-blame.service.ts
SepehrYahyaee 6c2d178686 YARA-830
2026-04-26 09:16:58 +03:30

1837 lines
60 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 {
assertBlameCaseForExpertTenant,
blameCaseAccessibleToExpert,
requireActorClientKey,
} from "src/helpers/tenant-scope";
import {
blameCaseStatusToReportBucket,
initialBlameExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import {
AllRequestDtoRs,
AllRequestDtoV2,
AllRequestDtoRsV2,
} from "./dto/all-request.dto";
import { UserType } from "src/Types&Enums/userType.enum";
import { SubmitReplyDto } from "./dto/reply.dto";
import { Types } from "mongoose";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { ResendRequestDto } from "./dto/resend.dto";
import { readFile } from "fs/promises";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
import { RequestManagementService } from "src/request-management/request-management.service";
import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
import { ExpertModel } from "src/users/entities/schema/expert.schema";
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
interface CheckedRequestEntry {
CheckedRequest?: {
actorId: string;
fullName: string;
};
[key: string]: any;
}
function statementToFormKey(
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
},
): string | null {
if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged";
if (statement.acceptsExpertOpinion) return "expertOpinion";
return null;
}
@Injectable()
export class ExpertBlameService {
private readonly logger = new Logger(ExpertBlameService.name);
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
constructor(
private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly clientDbService: ClientDbService,
private readonly blameVideoDbService: BlameVideoDbService,
private readonly blameVoiceDbService: BlameVoiceDbService,
private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
private readonly smsOrchestrationService: SmsOrchestrationService,
) { }
/** Load immutable profile fields from `expert` for the acting field expert. */
private async snapshotFieldExpert(actorId: string) {
const expertDoc = await this.expertDbService.findOne({
_id: new Types.ObjectId(actorId),
});
return snapshotFromFieldExpert(expertDoc as ExpertModel);
}
async findAll(actor: any): Promise<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.
*/
/**
* V2: Count blame cases for this experts tenant, grouped for dashboard:
* `IN_PROGRESS` = OPEN + WAITING_FOR_SECOND_PARTY; other {@link CaseStatus} keys unchanged.
*/
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
requireActorClientKey(actor);
const rows = await this.blameRequestDbService.find({}, { lean: true });
const buckets = initialBlameExpertReportBuckets();
for (const doc of rows as Record<string, unknown>[]) {
if (!this.blameDocIncludedInExpertTenantReport(doc, actor)) continue;
const st = String(doc.status ?? "");
const key = blameCaseStatusToReportBucket(st);
buckets.all++;
buckets[key] = (buckets[key] ?? 0) + 1;
}
return buckets;
}
/** Tenant + expert-initiated visibility (same as list, without queue filters). */
private blameDocIncludedInExpertTenantReport(
doc: Record<string, unknown>,
actor: { sub: string; clientKey?: string },
): boolean {
if (!blameCaseAccessibleToExpert(doc, actor)) {
return false;
}
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
return String(doc.initiatedByFieldExpertId) === String(actor.sub);
}
return true;
}
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try {
requireActorClientKey(actor);
const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find(
{
blameStatus: BlameStatus.DISAGREEMENT,
},
{ lean: true },
);
// Filter to show only:
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 3. Requests decided by current expert
// 4. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
if (!blameCaseAccessibleToExpert(doc, actor)) {
return false;
}
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId) {
if (String(initiatedByFieldExpertId) !== expertId) {
return false; // Only the initiating field expert can see this file
}
return true; // Initiating expert can see their expert-initiated file
}
const status = doc.status as string;
const decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId;
const hasDecision = !!decision?.decision;
// Fresh request (no decision yet)
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) {
return true;
}
// Request decided by current expert
if (decidedByExpertId && String(decidedByExpertId) === expertId) {
return true;
}
// Locked by current expert but no decision yet
const lockedBy = decision?.resend?.requestedByExpertId || (doc.workflow as any)?.lockedBy?.actorId;
if (status === CaseStatus.WAITING_FOR_EXPERT && lockedBy && String(lockedBy) === expertId) {
return true;
}
return false;
});
const staleIds = new Set<string>();
for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined;
if (!w?.locked) continue;
const la = w.lockedAt;
if (!la) {
staleIds.add(String(doc._id));
continue;
}
if (
Date.now() >=
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
) {
staleIds.add(String(doc._id));
}
}
await Promise.all(
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
);
for (const doc of visibleCases) {
if (!staleIds.has(String(doc._id))) continue;
const w = doc.workflow as Record<string, unknown>;
if (w) {
w.locked = false;
delete w.lockedAt;
delete w.lockedBy;
}
}
const items: AllRequestDtoV2[] = visibleCases.map(
(doc) => this.mapBlameRequestToListItemV2(doc),
);
return new AllRequestDtoRsV2(items);
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error));
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases",
);
}
}
private mapBlameRequestToListItemV2(
doc: Record<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;
vehicle?: {
name?: string;
inquiry?: {
mapped?: {
MapTypNam?: string;
CarName?: string;
};
};
};
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const firstParty = parties.find((p) => p.role === "FIRST");
const secondParty = parties.find((p) => p.role === "SECOND");
return {
requestId: String(doc._id),
status: String(doc.status ?? ""),
userComment: null,
requestCode: String(doc.publicId ?? ""),
date,
time,
updatedAtDate,
updatedAtTime,
lockFile: Boolean(workflow.locked),
lockTime: workflow.lockedAt
? new Date(workflow.lockedAt as string).toISOString()
: null,
type: String(doc.type ?? "THIRD_PARTY"),
blameStatus: String(doc.blameStatus ?? ""),
partiesInitialForms: {
firstParty: statementToFormKey(firstParty?.statement) ?? "",
secondParty: statementToFormKey(secondParty?.statement) ?? "",
},
partiesVehicles: {
firstPartyVehicle:
firstParty?.vehicle?.name ||
firstParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
firstParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
secondPartyVehicle:
secondParty?.vehicle?.name ||
secondParty?.vehicle?.inquiry?.mapped?.MapTypNam ||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
"",
}
};
}
/**
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
*/
private async expireBlameCaseWorkflowLockV2IfStale(
requestId: string,
): Promise<void> {
const request = await this.blameRequestDbService.findById(requestId);
if (!request?.workflow?.locked) {
return;
}
const lockedAt = request.workflow.lockedAt;
if (
lockedAt &&
Date.now() <
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
) {
return;
}
const lockedById = request.workflow.lockedBy?.actorId;
const filter: Record<string, unknown> = {
_id: new Types.ObjectId(requestId),
"workflow.locked": true,
};
if (lockedAt) {
filter["workflow.lockedAt"] = lockedAt;
} else if (lockedById) {
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
String(lockedById),
);
}
const cleared = await this.blameRequestDbService.findOneAndUpdate(
filter as any,
{
$set: { "workflow.locked": false },
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
},
{ new: false },
);
if (!cleared) {
return;
}
if (!request.expert?.decision && lockedById) {
const rid = new Types.ObjectId(requestId).toString();
try {
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(String(lockedById)) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: rid } as any,
},
);
this.logger.warn(
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
);
} catch (e) {
this.logger.warn(
`Expert stat rollback failed after blame lock expiry ${requestId}`,
e,
);
}
}
}
public unlockApi(request, timer) {
return setTimeout(async () => {
try {
const r = await this.requestManagementDbService.findOne(request._id);
const updateExp: any = {
lockFile: false,
unlockTime: null,
};
const shouldDecrementChecked =
r.blameStatus === ReqBlameStatus.ReviewRequest &&
!r.expertSubmitReply &&
r.actorLocked?.actorId;
if (shouldDecrementChecked) {
updateExp.blameStatus = ReqBlameStatus.UnChecked;
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(r.actorLocked.actorId) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: r._id.toString() },
},
);
this.logger.warn(
`Request ${r._id} unlocked without reply — expert stats rolled back.`,
);
}
await this.requestManagementDbService.findByIdAndUpdate(
r._id.toString(),
updateExp,
);
this.logger.log(`Unlock completed for request: ${r._id}`);
} catch (error) {
this.logger.error(`Failed to unlock request ${request._id}`, error);
}
}, timer);
}
public scheduleUnlock(request) {
const unlockDelay = new Date(request.unlockTime).getTime() - Date.now();
if (unlockDelay <= 0) return; // already expired
setTimeout(async () => {
try {
// Double-check latest state before unlocking
const current = await this.requestManagementDbService.findOne(
request._id,
);
if (!current.lockFile || current.expertSubmitReply) {
// Already unlocked or replied
return;
}
// If expiry passed
if (current.unlockTime && new Date(current.unlockTime) <= new Date()) {
const shouldRollbackStats =
current.blameStatus === ReqBlameStatus.ReviewRequest &&
!current.expertSubmitReply &&
current.actorLocked?.actorId;
const update: any = {
lockFile: false,
unlockTime: null,
lockTime: null,
};
if (shouldRollbackStats) {
update.blameStatus = ReqBlameStatus.UnChecked;
await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(current.actorLocked.actorId) },
{
$inc: { "requestStats.totalChecked": -1 },
$pull: { countedRequests: current._id.toString() },
},
);
this.logger.warn(
`Request ${current._id} auto-unlocked (no reply) — expert stats rolled back.`,
);
}
await this.requestManagementDbService.findByIdAndUpdate(
String(current._id),
update,
);
this.logger.log(`Auto-unlock completed for request: ${current._id}`);
}
} catch (err) {
this.logger.error(`Auto-unlock failed for ${request._id}`, err);
}
}, unlockDelay);
}
async findOne(requestId: string, actorId: string) {
// 1. Fetch the main request document
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// 1.5. Reject CAR_BODY type requests as they don't need expert review
if (request.type === "CAR_BODY") {
throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.",
);
}
// 2. Initial validation to ensure the expert has access
// Check if locked by current expert and lock is still active
const isLockedByCurrentExpert =
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
// Check if lock has expired
let isLockExpired = false;
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
isLockExpired = now >= unlockTime;
}
if (isLockedByCurrentExpert && !isLockExpired) {
// This is the correct expert, and the file is locked to them, which is fine.
// They can access it even if they closed the browser and came back.
} else if (
(request.lockFile && !isLockExpired) ||
request.blameStatus === ReqBlameStatus.ReviewRequest
) {
// The file is locked by someone else, or lock expired but status hasn't updated yet
// Only block if lock is still active and not by current expert
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
throw new BadRequestException("Request is locked by another expert");
}
}
// 3. Populate the resend links if the data exists
if (request.expertResendReply) {
const populatePartyLinks = async (
partyKey: "firstParty" | "secondParty",
) => {
const partyReply = request.expertResendReply[partyKey];
if (!partyReply) return;
// Populate the voice link
if (partyReply.voice) {
const voiceDoc = await this.userSignDbService.findById(
partyReply.voice.toString(),
);
if (voiceDoc) {
partyReply.voice = buildFileLink(voiceDoc.path);
}
}
// Populate the document links
if (partyReply.documents) {
for (const docType in partyReply.documents) {
const docId = partyReply.documents[docType];
if (docId) {
const doc = await this.blameDocumentDbService.findById(
docId.toString(),
);
if (doc) {
partyReply.documents[docType] = buildFileLink(doc.path); // Replace ID with URL
}
}
}
}
};
await populatePartyLinks("firstParty");
await populatePartyLinks("secondParty");
}
// 4. Populate the Signature Links from the correct reply object
// First, determine which reply object is the final, authoritative one.
const finalReply =
request.expertSubmitReplyFinal || request.expertSubmitReply;
if (finalReply) {
const populateSignatureLink = async (
commentField: "firstPartyComment" | "secondPartyComment",
) => {
const comment = finalReply[commentField];
// Check if the comment and its signDetail with a fileId exist
if (comment?.signDetail?.fileId) {
const signDoc = await this.userSignDbService.findById(
comment.signDetail.fileId.toString(),
);
if (signDoc) {
// Add a new 'fileUrl' property to the signDetail object
(comment.signDetail as any).fileUrl = buildFileLink(signDoc.path);
}
}
};
// Run the population for both parties' signatures on the correct reply object.
await populateSignatureLink("firstPartyComment");
await populateSignatureLink("secondPartyComment");
}
// 5. Format the date for display with Iran timezone (Asia/Tehran)
if (request.createdAt) {
const formattingOptions: Intl.DateTimeFormatOptions = {
timeZone: "Asia/Tehran",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
request.createdAt = new Date(request.createdAt).toLocaleString(
"fa-IR",
formattingOptions,
);
}
// 6. Return the fully populated request object
return request;
}
/**
* V2: Get blame case details by id from blameCases collection.
* Excludes history. Returns only 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, actor: any): Promise<Record<string, unknown>> {
try {
requireActorClientKey(actor);
const actorId = actor.sub;
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.",
);
}
// Access control
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
throw new ForbiddenException(
"Only the field expert who created this file can view and review it.",
);
}
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.",
);
}
const parties = Array.isArray(doc.parties) ? doc.parties : [];
const typedParties = parties as Array<{
vehicle?: {
inquiry?: {
mapped?: any;
};
};
evidence?: {
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
}>;
// Evidence (videos + voices)
for (const party of typedParties) {
if (!party.evidence) continue;
const evidence = party.evidence as Record<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;
}
}
// Date formatting
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields)
for (const party of typedParties) {
const veh = party?.vehicle as Record<string, unknown> | undefined;
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) {
delete veh.inquiry;
}
}
return doc;
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"findOneV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details",
);
}
}
async lockRequest(requestId: string, actorDetail) {
const existing = await this.requestManagementDbService.findOne(requestId);
if (!existing) {
throw new NotFoundException("Request not found");
}
if (existing.blameStatus === ReqBlameStatus.UserPending) {
throw new BadRequestException(
"Cannot lock request because blameStatus is UserPending",
);
}
const isLocked = !!existing.lockFile;
const lockedByCurrent =
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
const isLockExpired =
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime();
// Idempotent behavior: same expert can re-enter their locked file.
if (isLocked && lockedByCurrent && !isLockExpired) {
return { _id: requestId, lock: true, message: "Already locked by you" };
}
if (isLocked && !lockedByCurrent && !isLockExpired) {
throw new BadRequestException("Request is locked by another expert");
}
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
{
_id: requestId,
blameStatus: { $ne: ReqBlameStatus.UserPending },
},
{
$set: {
lockFile: true,
blameStatus: ReqBlameStatus.ReviewRequest,
unlockTime: fifteenMinutes,
lockTime: new Date(),
actorLocked: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
$push: {
actorsChecker: {
[ReqBlameStatus.ReviewRequest]: {
fullName: actorDetail.fullName,
actorId: new Types.ObjectId(actorDetail.sub),
},
Date: new Date(),
},
},
},
{ new: true },
);
if (!updateResult) {
throw new BadRequestException("Request already locked or invalid status");
}
// Update expert stats atomically (use findOneAndUpdate with conditions)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
this.scheduleUnlock(updateResult);
return { _id: requestId, lock: true };
}
/**
* V2: Lock blame case for 15 minutes (blameCases collection).
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt.
* Checks if existing lock is expired and allows re-locking.
*/
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
try {
const now = new Date();
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
// First, check the current state
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actorDetail);
// Validate request is available for expert review
if (
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
request.blameStatus !== BlameStatus.DISAGREEMENT
) {
throw new BadRequestException(
"Request is not available for expert review",
);
}
// Expert-initiated: only the initiating field expert can lock and review
if (
request.expertInitiated &&
request.initiatedByFieldExpertId &&
String(request.initiatedByFieldExpertId) !== actorDetail.sub
) {
throw new ForbiddenException(
"Only the field expert who created this file can lock and review it.",
);
}
// Check if locked and not expired
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
} else {
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
}
// Lock expired, allow re-locking (continue below)
}
}
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
// Lock the request (either unlocked or expired lock)
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
{
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.lockedBy": {
actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert",
actorRole: "expert",
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
},
},
},
);
if (!updateResult) {
throw new InternalServerErrorException("Failed to lock the request");
}
// Update expert stats (reusing existing helper)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
if (request.type === BlameRequestType.THIRD_PARTY) {
const phones = (request.parties || [])
.map((p: any) => p?.person?.phoneNumber)
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
const expertLastName =
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const phone of phones) {
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
{
receptor: phone,
publicId: request.publicId,
expertLastName,
},
);
}
}
return { _id: requestId, lock: true };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"lockRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to lock blame case",
);
}
}
/**
* V2: Request parties to resend documents/evidence (blameCases collection).
* Expert can request one or both parties to provide additional or better quality evidence.
*/
async resendRequestV2(
requestId: string,
resendDto: ResendRequestDto,
actor: any,
): Promise<{
requestId: string;
status: string;
parties: Array<{
partyId: string;
requestedItems: ResendItemType[];
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
description?: string;
}>;
}> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate request is locked by current expert
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before requesting document resend.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
if (lockedByActorId !== actorId) {
throw new ForbiddenException(
"Access denied. You are not the locked expert for this request.",
);
}
// Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() > lockExpiryTime) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.",
);
}
}
// Validate at least one party is specified
if (!resendDto.parties || resendDto.parties.length === 0) {
throw new BadRequestException(
"At least one party must be specified for resend request",
);
}
// Validate no existing expert decision
if (request.expert?.decision) {
throw new ForbiddenException(
"Cannot request resend after expert decision has been made.",
);
}
const allowedItems = new Set<string>(Object.values(ResendItemType));
const partyIdsOnFile = new Set(
(request.parties || [])
.map((p) => (p.person?.userId ? String(p.person.userId) : ""))
.filter(Boolean),
);
for (const party of resendDto.parties) {
const uid = String(party.partyId);
if (!partyIdsOnFile.has(uid)) {
throw new BadRequestException(
`partyId ${uid} is not a party userId on this blame request.`,
);
}
for (const item of party.requestedItems || []) {
if (!allowedItems.has(String(item))) {
throw new BadRequestException(
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
);
}
}
}
const now = new Date();
const resendSnapshot = await this.snapshotFieldExpert(actorId);
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
return {
partyId: new Types.ObjectId(uid),
requestedItems: party.requestedItems,
description: party.description || "",
requestedAt: now,
completed: false,
uploadedDocuments: {},
};
});
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_DOCUMENT_RESEND",
"workflow.nextStep": "WAITING_FOR_GUILT_DECISION",
"expert.resend": {
parties: partyResendRequests,
requestedAt: now,
requestedByExpertId: new Types.ObjectId(actorId),
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with resend request",
);
}
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
const requestIdToken = String(request._id);
const partyPhoneByUserId = new Map<string, string>();
for (const p of request.parties || []) {
const uid = p?.person?.userId ? String(p.person.userId) : "";
const phone = p?.person?.phoneNumber;
if (uid && typeof phone === "string" && phone.length > 0) {
partyPhoneByUserId.set(uid, phone);
}
}
for (const p of partyResendRequests) {
const uid = String((p as any).partyId);
const phone = partyPhoneByUserId.get(uid);
if (!phone) continue;
const role =
String(request.parties?.find((x: any) => String(x?.person?.userId) === uid)?.role) ===
"SECOND"
? "SECOND"
: "FIRST";
await this.smsOrchestrationService.sendResendDocumentsNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
link: this.smsOrchestrationService.buildBlamePartyLink(requestIdToken, role),
});
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
parties: partyResendRequests.map((p) => ({
partyId: String(p.partyId),
requestedItems: p.requestedItems as ResendItemType[],
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
description: p.description || undefined,
})),
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"resendRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to request document resend",
);
}
}
/**
* V2: Submit expert reply for blame case (blameCases collection).
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
*
* Note: V1 `replyRequest` also handled an “objection” path (`expertResendReply`) by writing
* `expertSubmitReplyFinal` on the legacy request document. Blame V2 uses a single
* `expert.decision` on `blameCases`; resend is modeled as `expert.resend` *before* the first
* decision. If you need a second decision after signatures/objections, extend the schema
* (e.g. decision revision) and branch here similarly to V1.
*/
async replyRequestV2(
requestId: string,
reply: SubmitReplyDto,
actor: any,
): Promise<{ requestId: string; status: string }> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
const expertProfileSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)),
description: reply.description,
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
...(expertProfileSnapshot && { expertProfileSnapshot }),
fields: {
accidentWay: {
id: reply.fields.accidentWay.id,
label: reply.fields.accidentWay.label,
},
accidentReason: {
id: reply.fields.accidentReason.id,
label: reply.fields.accidentReason.label,
fanavaran: reply.fields.accidentReason.fanavaran,
},
accidentType: {
id: reply.fields.accidentType.id,
label: reply.fields.accidentType.label,
},
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_SIGNATURES",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.WAITING_FOR_SIGNATURES,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
if (request.type === BlameRequestType.THIRD_PARTY) {
const requestIdToken = String(request._id);
const expertLastName =
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
for (const p of request.parties || []) {
const phone = p?.person?.phoneNumber;
if (!phone || typeof phone !== "string") continue;
const role = String(p?.role) === "SECOND" ? "SECOND" : "FIRST";
await this.smsOrchestrationService.sendSignatureReviewNotice({
receptor: phone,
fileKind: "blame",
publicId: request.publicId,
expertLastName,
link: this.smsOrchestrationService.buildBlamePartyLink(
requestIdToken,
role,
),
});
}
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"replyRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply",
);
}
}
async submitInPerson(
requestId: string,
actorId: string,
) {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
const inPersonSnapshot = await this.snapshotFieldExpert(actorId);
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: null,
description: "حضوری مراجعه شود.",
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
...(inPersonSnapshot && { expertProfileSnapshot: inPersonSnapshot }),
fields: {
accidentWay: null,
accidentReason: null,
accidentType: null,
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "COMPLETED",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.STOPPED,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"submitInPerson failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply",
);
}
}
private async updateDamageExpertStats(
expertId: string,
requestId: string,
type: "checked" | "handled",
) {
if (!expertId || !requestId || !["checked", "handled"].includes(type)) {
console.warn("Invalid expertId, requestId, or type");
return;
}
const expert = await this.expertDbService.findOne({
_id: new Types.ObjectId(expertId),
});
if (!expert) {
console.warn("Expert not found:", expertId);
return;
}
const requestIdStr = new Types.ObjectId(requestId).toString();
const countedRequestIds =
expert.countedRequests?.map((id) => id.toString()) || [];
if (type === "checked" && countedRequestIds.includes(requestIdStr)) {
console.log(
`Request ${requestIdStr} already checked for expert ${expertId}`,
);
return;
}
const update: any = { $inc: {}, $push: {} };
if (type === "checked") {
update.$inc["requestStats.totalChecked"] = 1;
update.$push["countedRequests"] = requestIdStr;
} else if (type === "handled") {
update.$inc["requestStats.totalHandled"] = 1;
if (countedRequestIds.includes(requestIdStr)) {
update.$inc["requestStats.totalChecked"] = -1;
}
if (!countedRequestIds.includes(requestIdStr)) {
update.$push["countedRequests"] = requestIdStr;
} else {
delete update.$push;
}
}
const updateResult = await this.expertDbService.findOneAndUpdate(
{ _id: new Types.ObjectId(expertId) },
update,
);
if (!updateResult) {
console.warn("Failed to update expert stats for:", expertId);
} else {
console.log(`Expert stats updated (${type}) for expert:`, expertId);
}
}
async replyRequest(requestId: string, reply: SubmitReplyDto, userId: string) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (String(request.actorLocked?.actorId) !== userId) {
throw new ForbiddenException(
"Access denied to this request. You are not the locked expert.",
);
}
// Check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
const now = Date.now();
if (now >= unlockTime) {
throw new ForbiddenException("Your lock time has expired.");
}
} else if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired.");
}
if (!request.lockFile) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const isObjection = !!request.expertResendReply;
const replyField = isObjection
? "expertSubmitReplyFinal"
: "expertSubmitReply";
if (!isObjection && request.expertSubmitReply) {
throw new ForbiddenException(
"This request already has an initial expert reply.",
);
}
if (isObjection && request.expertSubmitReplyFinal) {
throw new ForbiddenException(
"This request already has a final expert reply.",
);
}
const expertProfileSnapshot = await this.snapshotFieldExpert(userId);
const newReplyObject = {
description: reply.description,
submitTime: new Date(),
guiltyUserId: reply.guiltyUserId,
fields: {
accidentWay: {
id: reply.fields.accidentWay.id,
label: reply.fields.accidentWay.label,
},
accidentReason: {
id: reply.fields.accidentReason.id,
label: reply.fields.accidentReason.label,
fanavaran: reply.fields.accidentReason.fanavaran,
},
accidentType: {
id: reply.fields.accidentType.id,
label: reply.fields.accidentType.label,
},
},
firstPartyComment: request.expertSubmitReply?.firstPartyComment || null,
secondPartyComment: request.expertSubmitReply?.secondPartyComment || null,
...(expertProfileSnapshot && { expertProfileSnapshot }),
};
const updatePayload: any = {
$set: {
lockFile: false,
blameStatus: ReqBlameStatus.CheckedRequest,
[replyField]: newReplyObject,
},
$push: {
actorsChecker: {
[ReqBlameStatus.CheckedRequest]: request.actorLocked,
Date: new Date(),
},
},
};
if (isObjection) {
updatePayload.$set.expertSubmitReply = newReplyObject;
}
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
updatePayload,
);
return {
requestId: request._id,
blameStatus: ReqBlameStatus.CheckedRequest,
};
} catch (error) {
this.logger.error("Failed to submit expert reply:", error);
throw new Error("Failed to submit expert reply");
}
}
async sendAgainRequest(
requestId: string,
resend: any,
userId: string,
req: any,
) {
const request = await this.requestManagementDbService.findOne(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
if (String(request.actorLocked?.actorId) !== userId) {
throw new ForbiddenException("Access denied to this request");
}
if (request.expertSubmitReply) {
throw new ForbiddenException("Request already has an expert reply");
}
if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired or was not set");
}
const partyType = req.route.path.split("/")[4];
const resendSnapshot = await this.snapshotFieldExpert(userId);
switch (partyType) {
case "first": {
if (request.expertResendReply?.firstParty) {
throw new ForbiddenException(
"Request has an expert resend reply for the first party",
);
}
const { firstPartyId, firstPartyDescription } = resend;
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
lockFile: false,
blameStatus: ReqBlameStatus.UserPending,
"expertResendReply.firstParty.firstPartyId": firstPartyId,
"expertResendReply.firstParty.firstPartyDescription":
firstPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: {
actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked,
Date: new Date(),
},
},
},
);
} catch (error) {
this.logger.error("Failed to update for first party:", error);
throw error;
}
return {
requestId: request._id,
blameStatus: ReqBlameStatus.UserPending,
};
}
case "second": {
if (request.expertResendReply?.secondParty) {
throw new ForbiddenException(
"Request has an expert resend reply for the second party",
);
}
const { secondPartyId, secondPartyDescription } = resend;
try {
await this.requestManagementDbService.findAndUpdate(
{ _id: requestId },
{
lockFile: false,
blameStatus: ReqBlameStatus.UserPending,
"expertResendReply.secondParty.secondPartyId": secondPartyId,
"expertResendReply.secondParty.secondPartyDescription":
secondPartyDescription,
...(resendSnapshot && {
expertResendByExpertProfileSnapshot: resendSnapshot,
}),
$push: {
actorsChecker: {
[ReqBlameStatus.UserPending]: request.actorLocked,
Date: new Date(),
},
},
},
);
} catch (error) {
this.logger.error("Failed to update for second party:", error);
throw error;
}
// TODO notification for user parties
// TODO send SMS notification
// TODO send URI For USER
return {
requestId: request._id,
blameStatus: ReqBlameStatus.UserPending,
};
}
default:
throw new BadRequestException(
`Invalid party type in URL: ${partyType}`,
);
}
}
/// VIDEO SERVICE && VOICE SERVICE
// TODO add video service to Object Storage
async streamVideo(requestId): Promise<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 visitSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
const updated = await this.requestManagementDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{
blameStatus: ReqBlameStatus.InPersonVisit,
...(visitSnapshot && {
fieldExpertInPersonVisitProfileSnapshot: visitSnapshot,
}),
},
);
await this.expertDbService.updateStats(
actorDetail.sub,
"handled",
requestId,
);
return updated;
}
}