forked from Yara724/api
Fixed GET APIs for FileMaker and FileReviewer getting their own files
This commit is contained in:
@@ -2477,6 +2477,104 @@ export class ExpertClaimService {
|
||||
throw new HttpException(error.response, error.claimStatus);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame
|
||||
* file. Operates on the blame document (not the claim workflow lock).
|
||||
*
|
||||
* Rules:
|
||||
* - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated.
|
||||
* - Status must be WAITING_FOR_FILE_REVIEWER.
|
||||
* - No other reviewer may have taken it (assignedFileReviewerId absent/null).
|
||||
* - Idempotent: same reviewer calling again gets "already_assigned_to_you".
|
||||
*/
|
||||
private async assignFileReviewerToV4Blame(
|
||||
claimRequestId: string,
|
||||
claim: any,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
if (!claim.blameRequestId) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "This claim has no linked blame file.",
|
||||
});
|
||||
}
|
||||
|
||||
const blame = await this.blameRequestDbService.findById(
|
||||
String(claim.blameRequestId),
|
||||
);
|
||||
if (!blame) {
|
||||
throw new NotFoundException("Linked blame file not found.");
|
||||
}
|
||||
if (!(blame as any).isMadeByFileMaker) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
|
||||
});
|
||||
}
|
||||
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = (blame as any).assignedFileReviewerId;
|
||||
if (existing) {
|
||||
if (String(existing) === actor.sub) {
|
||||
return {
|
||||
success: true,
|
||||
status: "already_assigned_to_you",
|
||||
message: "You have already taken this file.",
|
||||
};
|
||||
}
|
||||
throw new ConflictException({
|
||||
success: false,
|
||||
status: "locked" satisfies ExpertFileAssignStatus,
|
||||
message: "Another FileReviewer has already taken this file.",
|
||||
});
|
||||
}
|
||||
|
||||
// Atomically claim it — findOneAndUpdate with null/missing guard
|
||||
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||
{
|
||||
_id: (blame as any)._id,
|
||||
$or: [
|
||||
{ assignedFileReviewerId: { $exists: false } },
|
||||
{ assignedFileReviewerId: null },
|
||||
],
|
||||
},
|
||||
{ $set: { assignedFileReviewerId: reviewerOid } },
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
// Another reviewer won the race
|
||||
throw new ConflictException({
|
||||
success: false,
|
||||
status: "locked" satisfies ExpertFileAssignStatus,
|
||||
message: "Another FileReviewer has already taken this file.",
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
this.logger.log(
|
||||
`FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: "assigned",
|
||||
assignedAt: now.toISOString(),
|
||||
message: "File assigned to you. Proceed with accident fields and field capture.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Assign the current damage expert to a claim for review (V2).
|
||||
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||
@@ -2490,7 +2588,10 @@ export class ExpertClaimService {
|
||||
role?: string;
|
||||
},
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
if ((actor as any).role !== RoleEnum.FIELD_EXPERT)
|
||||
if (
|
||||
(actor as any).role !== RoleEnum.FIELD_EXPERT &&
|
||||
(actor as any).role !== RoleEnum.FILE_REVIEWER
|
||||
)
|
||||
requireActorClientKey(actor);
|
||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||
|
||||
@@ -2499,6 +2600,13 @@ export class ExpertClaimService {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
|
||||
// FILE_REVIEWER: assign themselves to the linked blame (V4 flow).
|
||||
// This is a different path from the damage-expert lock — it operates on the
|
||||
// blame document and is idempotent (same reviewer can re-assign to themselves).
|
||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||
}
|
||||
|
||||
await this.assertExpertActorOnClaim(claim, actor);
|
||||
|
||||
const isFieldExpertOwner =
|
||||
@@ -3652,6 +3760,9 @@ export class ExpertClaimService {
|
||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||
return this.getFileReviewerClaimListV2(actor, query);
|
||||
}
|
||||
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||
return this.getFileMakerClaimListV2(actor, query);
|
||||
}
|
||||
requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
const clientKey = actor.clientKey as string;
|
||||
@@ -3924,51 +4035,145 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim list for FILE_REVIEWER — returns claims linked to sealed expert-initiated
|
||||
* IN_PERSON blame files (WAITING_FOR_FILE_REVIEWER, WAITING_FOR_EXPERT, COMPLETED)
|
||||
* that belong to this reviewer's insurance company.
|
||||
* Claim list for FILE_REVIEWER.
|
||||
*
|
||||
* Visibility rules:
|
||||
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
|
||||
* (a) not yet assigned to any reviewer, OR
|
||||
* (b) already assigned to THIS reviewer
|
||||
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer
|
||||
* (they completed the field work and the file is now in damage-expert queue)
|
||||
*
|
||||
* All scoped to this reviewer's insurance company via blame party clientId.
|
||||
*/
|
||||
private async getFileReviewerClaimListV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||
|
||||
// Find all sealed blame files (across statuses the reviewer cares about)
|
||||
const sealedBlames = (await this.blameRequestDbService.find(
|
||||
// Find V4 blame files visible to this reviewer
|
||||
const blames = (await this.blameRequestDbService.find(
|
||||
{
|
||||
isMadeByFileMaker: true,
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
status: {
|
||||
$in: [
|
||||
"WAITING_FOR_FILE_REVIEWER",
|
||||
"WAITING_FOR_EXPERT",
|
||||
"COMPLETED",
|
||||
$or: [
|
||||
// Open: sealed by FileMaker, not yet taken by any reviewer
|
||||
{
|
||||
status: "WAITING_FOR_FILE_REVIEWER",
|
||||
$or: [
|
||||
{ assignedFileReviewerId: { $exists: false } },
|
||||
{ assignedFileReviewerId: null },
|
||||
],
|
||||
},
|
||||
// Taken by this reviewer (any post-assignment status)
|
||||
{ assignedFileReviewerId: reviewerOid },
|
||||
],
|
||||
},
|
||||
{
|
||||
lean: true,
|
||||
select:
|
||||
"_id type parties blameStatus status expert.decision assignedFileReviewerId",
|
||||
},
|
||||
{ lean: true, select: "_id type parties blameStatus status expert.decision" },
|
||||
)) as any[];
|
||||
|
||||
if (sealedBlames.length === 0) {
|
||||
if (blames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
// Scope to this reviewer's insurer via blame party clientId
|
||||
const scopedBlameIds = sealedBlames
|
||||
.filter((b) => blameCaseTouchesClient(b, clientKey))
|
||||
.map((b) => b._id);
|
||||
const scopedBlames = blames.filter((b) =>
|
||||
blameCaseTouchesClient(b, clientKey),
|
||||
);
|
||||
|
||||
if (scopedBlameIds.length === 0) {
|
||||
if (scopedBlames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
const scopedBlameIds = scopedBlames.map((b) => b._id);
|
||||
const claims = (await this.claimCaseDbService.find({
|
||||
blameRequestId: { $in: scopedBlameIds },
|
||||
})) as any[];
|
||||
|
||||
const blameById = new Map<string, any>(
|
||||
sealedBlames.map((b) => [String(b._id), b]),
|
||||
scopedBlames.map((b) => [String(b._id), b]),
|
||||
);
|
||||
|
||||
const list = claims.map((c) => {
|
||||
const blame = c.blameRequestId
|
||||
? blameById.get(c.blameRequestId.toString())
|
||||
: undefined;
|
||||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||||
const lockActive = !!(
|
||||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||||
);
|
||||
const assignedToMe =
|
||||
blame?.assignedFileReviewerId &&
|
||||
String(blame.assignedFileReviewerId) === actor.sub;
|
||||
return {
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: c.status,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
currentStep: c.workflow?.currentStep || "",
|
||||
locked: lockActive,
|
||||
lockedBy:
|
||||
lockActive && c.workflow?.lockedBy
|
||||
? {
|
||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||
actorName: c.workflow.lockedBy.actorName,
|
||||
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||||
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||||
}
|
||||
: undefined,
|
||||
vehicle: v
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
needsFileReviewerCompletion:
|
||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER",
|
||||
assignedToMe: !!assignedToMe,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
return this.paginateClaimListV2(list, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim list for FILE_MAKER — returns only the claims linked to blame files
|
||||
* they personally created (V4 flow).
|
||||
*/
|
||||
private async getFileMakerClaimListV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const makerOid = new Types.ObjectId(actor.sub);
|
||||
|
||||
const makerBlames = (await this.blameRequestDbService.find(
|
||||
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
||||
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" },
|
||||
)) as any[];
|
||||
|
||||
if (makerBlames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
const blameIds = makerBlames.map((b) => b._id);
|
||||
const claims = (await this.claimCaseDbService.find({
|
||||
blameRequestId: { $in: blameIds },
|
||||
})) as any[];
|
||||
|
||||
const blameById = new Map<string, any>(
|
||||
makerBlames.map((b) => [String(b._id), b]),
|
||||
);
|
||||
|
||||
const list = claims.map((c) => {
|
||||
@@ -4008,6 +4213,9 @@ export class ExpertClaimService {
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
needsFileReviewerCompletion:
|
||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER",
|
||||
assignedFileReviewerId: blame?.assignedFileReviewerId
|
||||
? String(blame.assignedFileReviewerId)
|
||||
: undefined,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
@@ -4193,7 +4401,8 @@ export class ExpertClaimService {
|
||||
}): Promise<void> {
|
||||
if (
|
||||
(actor as any).role === RoleEnum.FIELD_EXPERT ||
|
||||
(actor as any).role === RoleEnum.FILE_REVIEWER
|
||||
(actor as any).role === RoleEnum.FILE_REVIEWER ||
|
||||
(actor as any).role === RoleEnum.FILE_MAKER
|
||||
) return;
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
@@ -4354,7 +4563,10 @@ export class ExpertClaimService {
|
||||
actor: any,
|
||||
): Promise<ClaimDetailV2ResponseDto> {
|
||||
const actorId = actor.sub;
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
if (
|
||||
actor.role !== RoleEnum.FIELD_EXPERT &&
|
||||
actor.role !== RoleEnum.FILE_MAKER
|
||||
) {
|
||||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||||
}
|
||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||
@@ -4374,31 +4586,51 @@ export class ExpertClaimService {
|
||||
const isFactorValidationPending =
|
||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||
|
||||
// Field experts and FileReviewers can always view IN_PERSON expert-initiated
|
||||
// claims regardless of claim status (they work across multiple non-standard steps).
|
||||
if (
|
||||
// FileMaker: can always view their own V4 files at any status.
|
||||
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
|
||||
if (!isOwn) {
|
||||
throw new ForbiddenException(
|
||||
"FileMakers can only view files they created.",
|
||||
);
|
||||
}
|
||||
// Fall through to the detail build below
|
||||
} else if (
|
||||
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
|
||||
// regardless of claim status (they work across multiple non-standard steps).
|
||||
actor.role === RoleEnum.FIELD_EXPERT ||
|
||||
actor.role === RoleEnum.FILE_REVIEWER
|
||||
) {
|
||||
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||||
// For FILE_REVIEWER: also accept if the linked blame is sealed for them
|
||||
const isFileReviewerEligible =
|
||||
actor.role === RoleEnum.FILE_REVIEWER &&
|
||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||
// FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR
|
||||
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
|
||||
const isV4Blame =
|
||||
(linkedBlame as any)?.isMadeByFileMaker &&
|
||||
linkedBlame?.expertInitiated &&
|
||||
linkedBlame?.creationMethod === "IN_PERSON" &&
|
||||
[
|
||||
"WAITING_FOR_FILE_REVIEWER",
|
||||
"WAITING_FOR_EXPERT",
|
||||
"COMPLETED",
|
||||
].includes(String(linkedBlame?.status ?? ""));
|
||||
if (!isFileReviewerEligible) {
|
||||
linkedBlame?.creationMethod === "IN_PERSON";
|
||||
if (!isV4Blame) {
|
||||
throw new ForbiddenException(
|
||||
"This claim is not accessible to you.",
|
||||
"FileReviewers can only access V4 FileMaker files.",
|
||||
);
|
||||
}
|
||||
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
|
||||
? String((linkedBlame as any).assignedFileReviewerId)
|
||||
: null;
|
||||
const isAssignedToMe =
|
||||
assignedReviewerId && assignedReviewerId === actorId;
|
||||
const isOpen =
|
||||
!assignedReviewerId &&
|
||||
String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER";
|
||||
if (!isAssignedToMe && !isOpen) {
|
||||
throw new ForbiddenException(
|
||||
"This file has been taken by another reviewer.",
|
||||
);
|
||||
}
|
||||
} else if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||||
throw new ForbiddenException("This claim is not accessible to you.");
|
||||
}
|
||||
// Fall through to the detail build below (skip the damage-expert gate)
|
||||
} else {
|
||||
} else if (actor.role !== RoleEnum.FILE_MAKER) {
|
||||
const isResendPending =
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||
|
||||
|
||||
@@ -109,6 +109,20 @@ export class BlameRequest {
|
||||
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
|
||||
@Prop({ type: String, enum: FilledBy })
|
||||
filledBy?: FilledBy;
|
||||
|
||||
/**
|
||||
* True when this file was created by a FileMaker (V4 split flow).
|
||||
* These files are sealed by the FileMaker and completed by a FileReviewer.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
isMadeByFileMaker?: boolean;
|
||||
|
||||
/**
|
||||
* The FileReviewer who claimed this file for completion (V4 flow only).
|
||||
* Set when a FileReviewer calls assign on this file; enforces one-reviewer-per-file.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId })
|
||||
assignedFileReviewerId?: Types.ObjectId;
|
||||
}
|
||||
|
||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||
|
||||
@@ -4151,15 +4151,12 @@ export class RequestManagementService {
|
||||
* Verify the current user (field expert) can access this BlameRequest (v2).
|
||||
*/
|
||||
private verifyExpertAccessForBlameV2(req: any, expert: any): void {
|
||||
// FILE_REVIEWER can access any sealed expert-initiated IN_PERSON file
|
||||
// (status WAITING_FOR_FILE_REVIEWER or beyond) scoped to their clientKey.
|
||||
// FILE_REVIEWER can access V4 FileMaker-sealed files.
|
||||
// They must be the assigned reviewer (or the file is still open for taking).
|
||||
if (expert?.role === RoleEnum.FILE_REVIEWER) {
|
||||
if (
|
||||
!req?.expertInitiated ||
|
||||
req?.creationMethod !== CreationMethod.IN_PERSON
|
||||
) {
|
||||
if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) {
|
||||
throw new ForbiddenException(
|
||||
"FileReviewer can only access expert-initiated IN_PERSON files.",
|
||||
"FileReviewer can only access V4 FileMaker files.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -4171,6 +4168,15 @@ export class RequestManagementService {
|
||||
"This file has not been sealed by a FileMaker yet.",
|
||||
);
|
||||
}
|
||||
// Enforce reviewer assignment: must be assigned to them or open (no reviewer yet)
|
||||
const assignedId = req.assignedFileReviewerId
|
||||
? String(req.assignedFileReviewerId)
|
||||
: null;
|
||||
if (assignedId && assignedId !== String(expert.sub)) {
|
||||
throw new ForbiddenException(
|
||||
"This file has been taken by another FileReviewer.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
|
||||
@@ -4441,6 +4447,7 @@ export class RequestManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
const isFileMakerRole = (expert as any)?.role === RoleEnum.FILE_MAKER;
|
||||
const created = await this.blameRequestDbService.create({
|
||||
publicId,
|
||||
requestNo: publicId,
|
||||
@@ -4460,6 +4467,7 @@ export class RequestManagementService {
|
||||
dto.creationMethod === CreationMethod.IN_PERSON
|
||||
? FilledBy.EXPERT
|
||||
: FilledBy.CUSTOMER,
|
||||
...(isFileMakerRole ? { isMadeByFileMaker: true } : {}),
|
||||
});
|
||||
|
||||
const requestId = String((created as any)._id);
|
||||
|
||||
Reference in New Issue
Block a user