This commit is contained in:
SepehrYahyaee
2026-06-01 13:06:09 +03:30
parent 06af79fa47
commit fde6464739

View File

@@ -303,66 +303,58 @@ export class ExpertBlameService {
requireActorClientKey(actor); requireActorClientKey(actor);
const expertId = actor.sub; const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find( const allCases = await this.blameRequestDbService.find(
{ { blameStatus: BlameStatus.DISAGREEMENT },
blameStatus: BlameStatus.DISAGREEMENT,
},
{ lean: true }, { 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( const visibleCases = (allCases as Record<string, unknown>[]).filter(
(doc) => { (doc) => {
if (!blameCaseAccessibleToExpert(doc, actor)) { // Always filter by tenant first
return false; 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 status = doc.status as string;
const decision = doc.expert as any; const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decision?.decidedByExpertId; const decidedByExpertId = decision?.decidedByExpertId
const hasDecision = !!decision?.decision; ? String(decision.decidedByExpertId)
: null;
const lockedById = String(
(doc.workflow as any)?.lockedBy?.actorId ?? "",
);
const lockEnforced =
(doc.workflow as any)?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any);
// Fresh request (no decision yet) // Expert-initiated files: only the initiating expert sees them
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) { if (doc.expertInitiated === true && doc.initiatedByFieldExpertId) {
return true; return String(doc.initiatedByFieldExpertId) === expertId;
} }
// Request decided by current expert // Bucket 1: Available — waiting, no decision, no active lock by someone else
if (decidedByExpertId && String(decidedByExpertId) === expertId) { const isAvailable =
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 && status === CaseStatus.WAITING_FOR_EXPERT &&
lockedBy && !decidedByExpertId &&
String(lockedBy) === expertId (!lockEnforced || lockedById === expertId);
) {
return true;
}
return false; // Bucket 2: Mine — decided by me
const isDecidedByMe =
!!decidedByExpertId && decidedByExpertId === expertId;
// Bucket 3: Mine — currently locked/assigned to me (in progress)
const isLockedByMe = lockEnforced && lockedById === expertId;
// Bucket 4: Mine — persistently assigned to me for review
const assignedForReviewById = String(
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
);
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === expertId;
return isAvailable || isDecidedByMe || isLockedByMe || isAssignedToMe;
}, },
); );
// Reconcile stale locks in-memory (same as before)
const staleIds = new Set<string>(); const staleIds = new Set<string>();
for (const doc of visibleCases) { for (const doc of visibleCases) {
const w = doc.workflow as Record<string, unknown> | undefined; const w = doc.workflow as Record<string, unknown> | undefined;
@@ -854,75 +846,79 @@ export class ExpertBlameService {
actor: any, actor: any,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
// requireActorClientKey(actor);
const actorId = actor.sub; const actorId = actor.sub;
await this.expireBlameCaseWorkflowLockV2IfStale(requestId); await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
const doc = const doc =
await this.blameRequestDbService.findByIdWithoutHistory(requestId); await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) { if (!doc) {
throw new NotFoundException("Request not found"); throw new NotFoundException("Request not found");
} }
// assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string; // Tenant check
if (type === BlameRequestType.CAR_BODY) { assertBlameCaseForExpertTenant(doc, actor);
if (doc.type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException( throw new ForbiddenException(
"CAR_BODY type requests are automatically handled and do not require expert review.", "CAR_BODY type requests are automatically handled and do not require expert review.",
); );
} }
// Access control // Expert-initiated: only the initiating expert
const expertInitiated = doc.expertInitiated === true;
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
if ( if (
expertInitiated && doc.expertInitiated === true &&
initiatedByFieldExpertId && doc.initiatedByFieldExpertId &&
String(initiatedByFieldExpertId) !== actorId String(doc.initiatedByFieldExpertId) !== actorId
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
"Only the field expert who created this file can view and review it.", "Only the field expert who created this file can view and review it.",
); );
} }
if (this.isBlameV2WorkflowLockCurrentlyEnforced(doc)) { const decision = (doc.expert as any)?.decision;
const w = doc.workflow as const decidedByExpertId = decision?.decidedByExpertId
| { lockedBy?: { actorId?: unknown } } ? String(decision.decidedByExpertId)
| undefined; : null;
const lockerId = String(w?.lockedBy?.actorId ?? ""); const lockedById = String((doc.workflow as any)?.lockedBy?.actorId ?? "");
if (lockerId && lockerId !== actorId) { const lockEnforced =
(doc.workflow as any)?.locked &&
this.isBlameV2WorkflowLockCurrentlyEnforced(doc);
const assignedForReviewById = String(
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
);
// Access gates — must satisfy at least one bucket
const isAvailable =
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
const isDecidedByMe = decidedByExpertId === actorId;
const isLockedByMe = lockEnforced && lockedById === actorId;
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === actorId;
if (!isAvailable && !isDecidedByMe && !isLockedByMe && !isAssignedToMe) {
// Give a specific reason if possible
if (lockEnforced && lockedById && lockedById !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
"This request is locked by another expert.", "This request is locked by another expert.",
); );
} }
} if (decidedByExpertId && decidedByExpertId !== actorId) {
throw new ForbiddenException(
const decision = (doc.expert as any)?.decision; "You do not have permission to view this request. It has been handled by another expert.",
const decidedByExpertId = decision?.decidedByExpertId; );
if (decidedByExpertId && String(decidedByExpertId) !== actorId) { }
throw new ForbiddenException( throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.", "You do not have permission to view this request.",
); );
} }
// Build evidence URLs
const parties = Array.isArray(doc.parties) ? doc.parties : []; const parties = Array.isArray(doc.parties) ? doc.parties : [];
for (const party of parties as Array<{
const typedParties = parties as Array<{ evidence?: Record<string, unknown>;
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; if (!party.evidence) continue;
const evidence = party.evidence as Record<string, unknown>; const evidence = party.evidence;
if (evidence.videoId) { if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById( const videoDoc = await this.blameVideoDbService.findById(
@@ -931,7 +927,7 @@ export class ExpertBlameService {
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path); if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
} }
if (evidence.voices && Array.isArray(evidence.voices)) { if (Array.isArray(evidence.voices)) {
const voiceUrls: string[] = []; const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) { for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById( const voiceDoc = await this.blameVoiceDbService.findById(
@@ -950,31 +946,31 @@ export class ExpertBlameService {
const updatedAt = doc.updatedAt const updatedAt = doc.updatedAt
? new Date(doc.updatedAt as string | number) ? new Date(doc.updatedAt as string | number)
: new Date(); : new Date();
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${createdDate} ${createdTime}`; doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields) // Strip heavy SandHub inquiry blob
for (const party of typedParties) { for (const party of parties as Array<{
const veh = party?.vehicle as Record<string, unknown> | undefined; vehicle?: Record<string, unknown>;
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) { }>) {
delete veh.inquiry; if (
party.vehicle &&
Object.prototype.hasOwnProperty.call(party.vehicle, "inquiry")
) {
delete party.vehicle.inquiry;
} }
} }
return doc; return doc;
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
this.logger.error( this.logger.error(
"findOneV2 failed", "findOneV2 failed",
requestId, requestId,
error instanceof Error ? error.stack : String(error), error instanceof Error ? error.stack : String(error),
); );
throw new InternalServerErrorException( throw new InternalServerErrorException(
error instanceof Error error instanceof Error
? error.message ? error.message