Merge pull request 'main' (#101) from s.yahyaee/yara724-api:main into main

Reviewed-on: Yara724/api#101
This commit is contained in:
2026-06-01 13:21:59 +03:30
3 changed files with 207 additions and 133 deletions

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;
@@ -564,6 +556,7 @@ export class ExpertBlameService {
const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry( const assigneeToPersist = blameWorkflowAssigneeToPersistOnLockExpiry(
request.workflow, request.workflow,
request,
); );
const expireSet: Record<string, unknown> = { "workflow.locked": false }; const expireSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) { if (assigneeToPersist) {
@@ -854,75 +847,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 +928,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 +947,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

View File

@@ -3294,22 +3294,26 @@ export class ExpertClaimService {
const claims = await this.claimCaseDbService.find({ const claims = await this.claimCaseDbService.find({
$or: [ $or: [
// Available claims: waiting for expert, not locked // Available: waiting, not locked
{ {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT, status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
"workflow.locked": { $ne: true }, "workflow.locked": { $ne: true },
// No persistent assignee (or expired without decision)
$or: [
{ "workflow.assignedForReviewBy": { $exists: false } },
{ "workflow.assignedForReviewBy": null },
],
}, },
// Expert reviewing but lock cleared (e.g. TTL expiry) — still list until status is reconciled // This expert's locked/in-progress claims
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
"workflow.locked": { $ne: true },
},
// This expert's own locked/in-progress claims
{ {
"workflow.locked": true, "workflow.locked": true,
"workflow.lockedBy.actorId": new Types.ObjectId(actorId), "workflow.lockedBy.actorId": new Types.ObjectId(actorId),
}, },
// User uploaded all factors; expert must approve/reject (unlocked queue) // This expert's persistently assigned claims (decided or resend/objection follow-up)
{
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
},
// Factor validation queue (open to all tenant experts — no lock needed)
{ {
$or: [ $or: [
{ {
@@ -3324,6 +3328,16 @@ export class ExpertClaimService {
}, },
], ],
}, },
// WAITING_FOR_USER_RESEND assigned to this expert
{
status: ClaimCaseStatus.WAITING_FOR_USER_RESEND,
"workflow.assignedForReviewBy.actorId": new Types.ObjectId(actorId),
},
// EXPERT_REVIEWING with stale lock (reconciliation will fix status, show in meantime)
{
status: ClaimCaseStatus.EXPERT_REVIEWING,
"workflow.locked": { $ne: true },
},
], ],
}); });
@@ -3331,10 +3345,12 @@ export class ExpertClaimService {
claimCaseTouchesClient(c, clientKey), claimCaseTouchesClient(c, clientKey),
); );
// Reconcile stale locks — if no decision was made, also clear assignedForReviewBy
const staleLockToReconcile = filtered.filter( const staleLockToReconcile = filtered.filter(
(c) => (c) =>
c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c), c.workflow?.locked && !this.isClaimV2WorkflowLockCurrentlyEnforced(c),
); );
const touchedIds = ( const touchedIds = (
await Promise.all( await Promise.all(
staleLockToReconcile.map(async (c) => { staleLockToReconcile.map(async (c) => {
@@ -3357,9 +3373,39 @@ export class ExpertClaimService {
} }
} }
// Post-filter: remove files that belong to another expert after reconciliation
const finalFiltered = filtered.filter((c) => {
const assignedId = String(c.workflow?.assignedForReviewBy?.actorId ?? "");
const lockEnforced =
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c);
const lockedById = String(c.workflow?.lockedBy?.actorId ?? "");
const isFactorValidation = claimIsAwaitingExpertFactorValidationV2(c);
// Factor validation is open to all tenant experts
if (isFactorValidation) return true;
// Available: no assignee, not locked by someone else
const isAvailable =
c.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT &&
!assignedId &&
(!lockEnforced || lockedById === actorId);
// Mine: assigned to me (covers decided, resend, objection follow-up)
const isAssignedToMe = !!assignedId && assignedId === actorId;
// Mine: currently locked by me
const isLockedByMe = lockEnforced && lockedById === actorId;
// Mine: resend pending and I'm the assigned expert
const isResendMine =
c.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND && isAssignedToMe;
return isAvailable || isAssignedToMe || isLockedByMe || isResendMine;
});
const blameIds = [ const blameIds = [
...new Set( ...new Set(
filtered finalFiltered
.map((c) => c.blameRequestId?.toString()) .map((c) => c.blameRequestId?.toString())
.filter((id): id is string => !!id), .filter((id): id is string => !!id),
), ),
@@ -3375,7 +3421,7 @@ export class ExpertClaimService {
blames.map((b) => [String(b._id), b]), blames.map((b) => [String(b._id), b]),
); );
const list = filtered.map((c) => { const list = finalFiltered.map((c) => {
const awaitingFactorValidation = const awaitingFactorValidation =
claimIsAwaitingExpertFactorValidationV2(c); claimIsAwaitingExpertFactorValidationV2(c);
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
@@ -3390,6 +3436,7 @@ export class ExpertClaimService {
c.status === ClaimCaseStatus.EXPERT_REVIEWING c.status === ClaimCaseStatus.EXPERT_REVIEWING
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
: c.status; : c.status;
return { return {
claimRequestId: c._id.toString(), claimRequestId: c._id.toString(),
publicId: c.publicId, publicId: c.publicId,
@@ -3406,11 +3453,7 @@ export class ExpertClaimService {
} }
: undefined, : undefined,
vehicle: v vehicle: v
? { ? { carName: v.carName, carModel: v.carModel, carType: v.carType }
carName: v.carName,
carModel: v.carModel,
carType: v.carType,
}
: undefined, : undefined,
...fileCtx, ...fileCtx,
createdAt: c.createdAt, createdAt: c.createdAt,
@@ -3727,6 +3770,7 @@ export class ExpertClaimService {
const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry( const assigneeToPersist = claimWorkflowAssigneeToPersistOnLockExpiry(
claim.workflow, claim.workflow,
claim,
); );
const expireLockSet: Record<string, unknown> = { "workflow.locked": false }; const expireLockSet: Record<string, unknown> = { "workflow.locked": false };
if (assigneeToPersist) { if (assigneeToPersist) {
@@ -3795,21 +3839,53 @@ export class ExpertClaimService {
claim.status === ClaimCaseStatus.EXPERT_REVIEWING; claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
const isFactorValidationPending = const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim); claimIsAwaitingExpertFactorValidationV2(claim);
const isResendPending =
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
if (!isDamageExpertPhase && !isFactorValidationPending) { if (
!isDamageExpertPhase &&
!isFactorValidationPending &&
!isResendPending
) {
throw new ForbiddenException( throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`, `This claim is not available for expert review. Current status: ${claim.status}`,
); );
} }
const lockBlocksOthers = this.isClaimV2WorkflowLockCurrentlyEnforced(claim); // Ownership access control — same 4-bucket logic as list
if (lockBlocksOthers) { const assignedForReviewById = String(
const lockerId = claim.workflow?.lockedBy?.actorId?.toString(); (claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
if (lockerId && lockerId !== actorId) { );
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
const isAvailable =
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
const isAssignedToMe =
!!assignedForReviewById && assignedForReviewById === actorId;
const isLockedByMe = lockEnforced && lockedById === actorId;
// Factor validation is open to all tenant experts (no individual ownership)
const isFactorValidationOpen = isFactorValidationPending;
if (
!isAvailable &&
!isAssignedToMe &&
!isLockedByMe &&
!isFactorValidationOpen
) {
if (lockEnforced && lockedById && lockedById !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`, `This claim is locked by another expert: ${claim.workflow?.lockedBy?.actorName}`,
); );
} }
if (assignedForReviewById && assignedForReviewById !== actorId) {
throw new ForbiddenException(
"This claim has been assigned to another expert.",
);
}
throw new ForbiddenException(
"You do not have permission to view this claim.",
);
} }
const hasCapture = (data: any, key: string) => const hasCapture = (data: any, key: string) =>

View File

@@ -25,9 +25,8 @@ export function resolvePersistentReviewAssigneeId(
if (fromField != null && String(fromField).length > 0) { if (fromField != null && String(fromField).length > 0) {
return String(fromField); return String(fromField);
} }
const fromHistory = history?.find( const fromHistory = history?.find((h) => h.type === assignedHistoryType)
(h) => h.type === assignedHistoryType, ?.actor?.actorId;
)?.actor?.actorId;
if (fromHistory != null && String(fromHistory).length > 0) { if (fromHistory != null && String(fromHistory).length > 0) {
return String(fromHistory); return String(fromHistory);
} }
@@ -43,8 +42,7 @@ export function buildExpertAssignConflictForOtherReviewer(
message: string; message: string;
lockedBy: { actorId: string; actorName?: string; lockedAt?: string }; lockedBy: { actorId: string; actorName?: string; lockedAt?: string };
} { } {
const assignee = const assignee = workflow?.assignedForReviewBy ?? workflow?.lockedBy;
workflow?.assignedForReviewBy ?? workflow?.lockedBy;
const lockedAt = workflow?.lockedAt; const lockedAt = workflow?.lockedAt;
return { return {
success: false, success: false,
@@ -53,25 +51,28 @@ export function buildExpertAssignConflictForOtherReviewer(
lockedBy: { lockedBy: {
actorId: assigneeId, actorId: assigneeId,
actorName: assignee?.actorName, actorName: assignee?.actorName,
lockedAt: lockedAt lockedAt: lockedAt ? new Date(lockedAt as Date).toISOString() : undefined,
? new Date(lockedAt as Date).toISOString()
: undefined,
}, },
}; };
} }
/** Copy lock holder into `assignedForReviewBy` when persisting a TTL unlock. */
export function blameWorkflowAssigneeToPersistOnLockExpiry( export function blameWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined, workflow: any,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined { request: any,
if (workflow?.assignedForReviewBy) { ) {
return undefined; const hasDecision = !!request?.expert?.decision;
} return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null;
return workflow?.lockedBy;
} }
export function claimWorkflowAssigneeToPersistOnLockExpiry( export function claimWorkflowAssigneeToPersistOnLockExpiry(
workflow: WorkflowAssigneeRef | undefined, workflow: any,
): WorkflowAssigneeRef["assignedForReviewBy"] | undefined { claim: any,
return blameWorkflowAssigneeToPersistOnLockExpiry(workflow); ): typeof workflow.assignedForReviewBy | null {
// If a decision or resend was already submitted, preserve ownership
const hasDecision =
!!claim?.evaluation?.damageExpertReply ||
!!claim?.evaluation?.damageExpertReplyFinal ||
!!claim?.evaluation?.damageExpertResend;
return hasDecision ? (workflow?.assignedForReviewBy ?? null) : null;
} }