forked from Yara724/api
Merge pull request 'YARA-951' (#83) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#83
This commit is contained in:
@@ -7,6 +7,12 @@ export class CaptchaResponseDto {
|
|||||||
})
|
})
|
||||||
captchaId: string;
|
captchaId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Sample text",
|
||||||
|
example: "sb20xe"
|
||||||
|
})
|
||||||
|
text: string
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
|
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class CaptchaChallengeService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
captchaId,
|
captchaId,
|
||||||
|
text: generated.text,
|
||||||
image: generated.image,
|
image: generated.image,
|
||||||
expiresAt: generated.expiresAt,
|
expiresAt: generated.expiresAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -159,5 +159,5 @@ export class ClaimCase {
|
|||||||
|
|
||||||
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
||||||
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
||||||
|
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });
|
||||||
|
|
||||||
|
|||||||
38
src/common/dto/expert-file-assign-result.dto.ts
Normal file
38
src/common/dto/expert-file-assign-result.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export type ExpertFileAssignStatus =
|
||||||
|
| "assigned"
|
||||||
|
| "already_assigned_to_you"
|
||||||
|
| "locked"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export class ExpertFileAssignResultDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ["assigned", "already_assigned_to_you", "locked", "unavailable"],
|
||||||
|
example: "assigned",
|
||||||
|
})
|
||||||
|
status: ExpertFileAssignStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: "You already started processing this request",
|
||||||
|
})
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "When the expert was assigned (ISO 8601).",
|
||||||
|
example: "2026-05-24T12:00:00.000Z",
|
||||||
|
})
|
||||||
|
assignedAt?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Present when status is locked (another expert).",
|
||||||
|
})
|
||||||
|
lockedBy?: {
|
||||||
|
actorId: string;
|
||||||
|
actorName?: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -23,6 +24,10 @@ import {
|
|||||||
} from "src/helpers/expert-portfolio";
|
} from "src/helpers/expert-portfolio";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
import {
|
||||||
|
ExpertFileAssignResultDto,
|
||||||
|
ExpertFileAssignStatus,
|
||||||
|
} from "src/common/dto/expert-file-assign-result.dto";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -1000,118 +1005,222 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Lock blame case for 15 minutes (blameCases collection).
|
* Assign the current field expert to a blame case for review (V2).
|
||||||
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt.
|
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||||
* Checks if existing lock is expired and allows re-locking.
|
|
||||||
*/
|
*/
|
||||||
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
async assignBlameCaseForReviewV2(
|
||||||
try {
|
requestId: string,
|
||||||
const now = new Date();
|
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||||
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
// First, check the current state
|
if (!request) {
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
throw new NotFoundException("Request not found");
|
||||||
if (!request) {
|
}
|
||||||
throw new NotFoundException("Request not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
assertBlameCaseForExpertTenant(request, actorDetail);
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
// Validate request is available for expert review
|
if (request.type === BlameRequestType.CAR_BODY) {
|
||||||
if (
|
throw new BadRequestException({
|
||||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
success: false,
|
||||||
request.blameStatus !== BlameStatus.DISAGREEMENT
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
) {
|
message: "CAR_BODY cases do not require expert review",
|
||||||
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 (`expiredAt` or lockedAt + TTL)
|
|
||||||
if (
|
|
||||||
request.workflow?.locked &&
|
|
||||||
this.isBlameV2WorkflowLockCurrentlyEnforced(request)
|
|
||||||
) {
|
|
||||||
const lockedByActorId = String(
|
|
||||||
request.workflow.lockedBy?.actorId ?? "",
|
|
||||||
);
|
|
||||||
if (lockedByActorId === actorDetail.sub) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"You have already locked this request",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Request is currently locked by another expert",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.expiredAt": new Date(
|
|
||||||
now.getTime() + this.blameV2LockTtlMs,
|
|
||||||
),
|
|
||||||
"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");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.recordBlameExpertActivity({
|
|
||||||
expertId: String(actorDetail.sub),
|
|
||||||
requestId: String(requestId),
|
|
||||||
eventType: ExpertFileActivityType.CHECKED,
|
|
||||||
tenantId:
|
|
||||||
String(request.parties?.[0]?.person?.clientId ?? "") ||
|
|
||||||
String(request.parties?.[1]?.person?.clientId ?? ""),
|
|
||||||
idempotencyKey: `blame:${requestId}:checked:${actorDetail.sub}`,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (request.type === BlameRequestType.THIRD_PARTY) {
|
if (
|
||||||
const phones = (request.parties || [])
|
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
||||||
.map((p: any) => p?.person?.phoneNumber)
|
request.blameStatus !== BlameStatus.DISAGREEMENT
|
||||||
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
|
) {
|
||||||
const expertLastName =
|
throw new BadRequestException({
|
||||||
actorDetail?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
success: false,
|
||||||
for (const phone of phones) {
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
message: "Request is not available for expert review",
|
||||||
{
|
});
|
||||||
receptor: phone,
|
}
|
||||||
fileKind: "blame",
|
|
||||||
publicId: request.publicId,
|
if (
|
||||||
expertLastName,
|
request.expertInitiated &&
|
||||||
},
|
request.initiatedByFieldExpertId &&
|
||||||
);
|
String(request.initiatedByFieldExpertId) !== actor.sub
|
||||||
}
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only the field expert who created this file can review it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertOid = new Types.ObjectId(actor.sub);
|
||||||
|
const lockedById = String(request.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
const lockEnforced =
|
||||||
|
request.workflow?.locked &&
|
||||||
|
this.isBlameV2WorkflowLockCurrentlyEnforced(request);
|
||||||
|
|
||||||
|
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is already being processed by another expert",
|
||||||
|
lockedBy: {
|
||||||
|
actorId: lockedById,
|
||||||
|
actorName: request.workflow?.lockedBy?.actorName,
|
||||||
|
lockedAt: request.workflow?.lockedAt
|
||||||
|
? new Date(request.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockEnforced && lockedById === actor.sub) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
message: "You already started processing this request",
|
||||||
|
assignedAt: request.workflow?.lockedAt
|
||||||
|
? new Date(request.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lockSnapshot = await this.snapshotFieldExpert(actor.sub);
|
||||||
|
const lockUpdate = {
|
||||||
|
$set: {
|
||||||
|
"workflow.locked": true,
|
||||||
|
"workflow.lockedAt": now,
|
||||||
|
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
|
||||||
|
"workflow.lockedBy": {
|
||||||
|
actorId: expertOid,
|
||||||
|
actorName: actor.fullName || "Unknown Expert",
|
||||||
|
actorRole: "expert",
|
||||||
|
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "BLAME_ASSIGNED",
|
||||||
|
actor: {
|
||||||
|
actorId: expertOid,
|
||||||
|
actorName: actor.fullName || "Unknown Expert",
|
||||||
|
actorType: "field_expert",
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
metadata: { note: "Expert assigned via review assign endpoint" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignFilter: Record<string, unknown> = {
|
||||||
|
_id: new Types.ObjectId(requestId),
|
||||||
|
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||||
|
blameStatus: BlameStatus.DISAGREEMENT,
|
||||||
|
$or: [
|
||||||
|
{ "workflow.locked": { $ne: true } },
|
||||||
|
{ "workflow.locked": false },
|
||||||
|
{ "workflow.expiredAt": { $lte: now } },
|
||||||
|
{ "workflow.lockedBy.actorId": expertOid },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.expertInitiated) {
|
||||||
|
assignFilter.initiatedByFieldExpertId = expertOid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||||
|
assignFilter as any,
|
||||||
|
lockUpdate,
|
||||||
|
{ new: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
const latest = await this.blameRequestDbService.findById(requestId);
|
||||||
|
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
if (
|
||||||
|
latest?.workflow?.locked &&
|
||||||
|
this.isBlameV2WorkflowLockCurrentlyEnforced(latest) &&
|
||||||
|
otherId &&
|
||||||
|
otherId !== actor.sub
|
||||||
|
) {
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is already being processed by another expert",
|
||||||
|
lockedBy: {
|
||||||
|
actorId: otherId,
|
||||||
|
actorName: latest.workflow?.lockedBy?.actorName,
|
||||||
|
lockedAt: latest.workflow?.lockedAt
|
||||||
|
? new Date(latest.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request could not be assigned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { _id: requestId, lock: true };
|
await this.recordBlameExpertActivity({
|
||||||
|
expertId: String(actor.sub),
|
||||||
|
requestId: String(requestId),
|
||||||
|
eventType: ExpertFileActivityType.CHECKED,
|
||||||
|
tenantId:
|
||||||
|
String(updated.parties?.[0]?.person?.clientId ?? "") ||
|
||||||
|
String(updated.parties?.[1]?.person?.clientId ?? ""),
|
||||||
|
idempotencyKey: `blame:${requestId}:checked:${actor.sub}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated.type === BlameRequestType.THIRD_PARTY) {
|
||||||
|
const phones = (updated.parties || [])
|
||||||
|
.map((p: any) => p?.person?.phoneNumber)
|
||||||
|
.filter((p: unknown): p is string => typeof p === "string" && p.length > 0);
|
||||||
|
const expertLastName =
|
||||||
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
|
for (const phone of phones) {
|
||||||
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
|
{
|
||||||
|
receptor: phone,
|
||||||
|
fileKind: "blame",
|
||||||
|
publicId: updated.publicId,
|
||||||
|
expertLastName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Blame case ${requestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "assigned",
|
||||||
|
assignedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Lock blame case for 15 minutes (blameCases collection).
|
||||||
|
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
|
||||||
|
*/
|
||||||
|
async lockRequestV2(
|
||||||
|
requestId: string,
|
||||||
|
actorDetail: any,
|
||||||
|
): Promise<{ _id: string; lock: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const result = await this.assignBlameCaseForReviewV2(requestId, actorDetail);
|
||||||
|
return {
|
||||||
|
_id: requestId,
|
||||||
|
lock: true,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof ConflictException) {
|
||||||
|
const body = error.getResponse();
|
||||||
|
throw new BadRequestException(body);
|
||||||
|
}
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"lockRequestV2 failed",
|
"lockRequestV2 failed",
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
|
||||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
@@ -79,7 +81,36 @@ export class ExpertBlameV2Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/assign")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Check availability and assign blame case to this expert",
|
||||||
|
description:
|
||||||
|
"Call before opening a case from the list. Returns `assigned` when the case was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Does not replace PUT lock — use this for list UI availability.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
|
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: "Another expert is processing this case",
|
||||||
|
type: ExpertFileAssignResultDto,
|
||||||
|
})
|
||||||
|
async assignForReview(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
|
try {
|
||||||
|
return await this.expertBlameService.assignBlameCaseForReviewV2(id, actor);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to assign blame case",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Put("lock/:id")
|
@Put("lock/:id")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Lock blame case for review (legacy)",
|
||||||
|
description:
|
||||||
|
"Same assignment logic as POST `:id/assign`, but returns the legacy `{ _id, lock }` shape and maps conflicts to 400 instead of 409.",
|
||||||
|
})
|
||||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
|
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import { ClaimStepsEnum } from "src/Types&Enums/claim-request-management/steps.e
|
|||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
import { UserType } from "src/Types&Enums/userType.enum";
|
||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
|
import {
|
||||||
|
ExpertFileAssignResultDto,
|
||||||
|
ExpertFileAssignStatus,
|
||||||
|
} from "src/common/dto/expert-file-assign-result.dto";
|
||||||
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||||||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
@@ -2341,6 +2345,218 @@ export class ExpertClaimService {
|
|||||||
throw new HttpException(error.response, error.claimStatus);
|
throw new HttpException(error.response, error.claimStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign the current damage expert to a claim for review (V2).
|
||||||
|
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||||
|
*/
|
||||||
|
async assignClaimForReviewV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||||
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
|
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
|
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
|
const isDamageReviewLock =
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
|
|
||||||
|
if (!isDamageReviewLock && !isFactorValidationLock) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is not available for expert review",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
const lockEnforced =
|
||||||
|
claim.workflow?.locked &&
|
||||||
|
this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||||||
|
|
||||||
|
if (lockEnforced && lockedById && lockedById !== actor.sub) {
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is already being processed by another expert",
|
||||||
|
lockedBy: {
|
||||||
|
actorId: lockedById,
|
||||||
|
actorName: claim.workflow?.lockedBy?.actorName,
|
||||||
|
lockedAt: claim.workflow?.lockedAt
|
||||||
|
? new Date(claim.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockEnforced && lockedById === actor.sub) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "already_assigned_to_you",
|
||||||
|
message: "You already started processing this request",
|
||||||
|
assignedAt: claim.workflow?.lockedAt
|
||||||
|
? new Date(claim.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertOid = new Types.ObjectId(actor.sub);
|
||||||
|
const now = new Date();
|
||||||
|
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
|
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
||||||
|
|
||||||
|
const baseLockUpdate: Record<string, unknown> = {
|
||||||
|
"workflow.locked": true,
|
||||||
|
"workflow.lockedAt": now,
|
||||||
|
"workflow.expiredAt": expiredAt,
|
||||||
|
"workflow.lockedBy": {
|
||||||
|
actorId: expertOid,
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorRole: "damage_expert",
|
||||||
|
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
||||||
|
},
|
||||||
|
"workflow.preLockQueueSnapshot": {
|
||||||
|
claimStatus: claim.claimStatus,
|
||||||
|
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||||||
|
claim.workflow?.currentStep,
|
||||||
|
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
||||||
|
),
|
||||||
|
...(claim.workflow?.nextStep != null
|
||||||
|
? {
|
||||||
|
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
|
||||||
|
claim.workflow.nextStep,
|
||||||
|
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "CLAIM_ASSIGNED",
|
||||||
|
actor: {
|
||||||
|
actorId: expertOid,
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: "damage_expert",
|
||||||
|
},
|
||||||
|
timestamp: now,
|
||||||
|
metadata: {
|
||||||
|
note: isFactorValidationLock
|
||||||
|
? "Expert assigned for factor validation review"
|
||||||
|
: "Expert assigned via review assign endpoint",
|
||||||
|
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lockUpdate: Record<string, unknown> = isFactorValidationLock
|
||||||
|
? baseLockUpdate
|
||||||
|
: {
|
||||||
|
...baseLockUpdate,
|
||||||
|
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
||||||
|
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignFilter: Record<string, unknown> = {
|
||||||
|
_id: new Types.ObjectId(claimRequestId),
|
||||||
|
$or: [
|
||||||
|
{ "workflow.locked": { $ne: true } },
|
||||||
|
{ "workflow.locked": false },
|
||||||
|
{ "workflow.expiredAt": { $lte: now } },
|
||||||
|
{ "workflow.lockedBy.actorId": expertOid },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFactorValidationLock) {
|
||||||
|
assignFilter.claimStatus = ClaimStatus.UNDER_REVIEW;
|
||||||
|
assignFilter["workflow.currentStep"] =
|
||||||
|
ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
|
assignFilter.status = {
|
||||||
|
$in: [
|
||||||
|
ClaimCaseStatus.EXPERT_VALIDATING_REPAIR_FACTORS,
|
||||||
|
ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
assignFilter.status = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.claimCaseDbService.findOneAndUpdate(
|
||||||
|
assignFilter as any,
|
||||||
|
lockUpdate,
|
||||||
|
{ new: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
|
const latest = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
const otherId = String(latest?.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
if (
|
||||||
|
latest?.workflow?.locked &&
|
||||||
|
this.isClaimV2WorkflowLockCurrentlyEnforced(latest) &&
|
||||||
|
otherId &&
|
||||||
|
otherId !== actor.sub
|
||||||
|
) {
|
||||||
|
throw new ConflictException({
|
||||||
|
success: false,
|
||||||
|
status: "locked" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request is already being processed by another expert",
|
||||||
|
lockedBy: {
|
||||||
|
actorId: otherId,
|
||||||
|
actorName: latest.workflow?.lockedBy?.actorName,
|
||||||
|
lockedAt: latest.workflow?.lockedAt
|
||||||
|
? new Date(latest.workflow.lockedAt as Date).toISOString()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new BadRequestException({
|
||||||
|
success: false,
|
||||||
|
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||||
|
message: "Request could not be assigned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.recordClaimExpertActivity({
|
||||||
|
expertId: String(actor.sub),
|
||||||
|
tenantId: this.claimActivityTenantId(updated, actor),
|
||||||
|
claimId: String(claimRequestId),
|
||||||
|
eventType: ExpertFileActivityType.CHECKED,
|
||||||
|
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerPhone = await this.resolveClaimOwnerPhone(updated);
|
||||||
|
if (ownerPhone) {
|
||||||
|
const expertLastName =
|
||||||
|
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
||||||
|
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
||||||
|
{
|
||||||
|
receptor: ownerPhone,
|
||||||
|
fileKind: "claim",
|
||||||
|
publicId: updated.publicId,
|
||||||
|
expertLastName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Claim ${claimRequestId} assigned to expert ${actor.sub} at ${now.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: "assigned",
|
||||||
|
assignedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Lock a claim request for expert review.
|
* V2: Lock a claim request for expert review.
|
||||||
*
|
*
|
||||||
@@ -2362,136 +2578,42 @@ export class ExpertClaimService {
|
|||||||
* `workflow.currentStep` untouched so the claim stays in the factor-validation
|
* `workflow.currentStep` untouched so the claim stays in the factor-validation
|
||||||
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
|
* queue (so `expireClaimWorkflowLockV2IfStale` and the queue endpoint keep
|
||||||
* treating it correctly when the lock expires).
|
* treating it correctly when the lock expires).
|
||||||
|
*
|
||||||
|
* Delegates to {@link assignClaimForReviewV2}; maps conflict to BadRequest for legacy clients.
|
||||||
*/
|
*/
|
||||||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||||||
requireActorClientKey(actor);
|
try {
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
const result = await this.assignClaimForReviewV2(claimRequestId, actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const response: Record<string, unknown> = {
|
||||||
|
claimRequestId,
|
||||||
if (!claim) {
|
locked: true,
|
||||||
throw new NotFoundException('Claim request not found');
|
message: result.message,
|
||||||
}
|
};
|
||||||
|
if (result.status === "assigned") {
|
||||||
assertClaimCaseForTenant(claim, actor);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (claim?.status === ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim);
|
response.status = ClaimCaseStatus.EXPERT_REVIEWING;
|
||||||
const isDamageReviewLock =
|
response.claimStatus = ClaimStatus.UNDER_REVIEW;
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
response.currentStep = ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||||||
|
}
|
||||||
if (!isDamageReviewLock && !isFactorValidationLock) {
|
}
|
||||||
throw new BadRequestException(
|
return response;
|
||||||
`Claim is not available for locking. Current status: ${claim.status}`,
|
} catch (error) {
|
||||||
|
if (error instanceof ConflictException) {
|
||||||
|
const body = error.getResponse();
|
||||||
|
throw new BadRequestException(body);
|
||||||
|
}
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
"lockClaimRequestV2 failed",
|
||||||
|
claimRequestId,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
throw new HttpException(
|
||||||
|
error instanceof Error ? error.message : "Failed to lock claim",
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
claim.workflow?.locked &&
|
|
||||||
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already locked by this same expert — idempotent
|
|
||||||
if (
|
|
||||||
claim.workflow?.locked &&
|
|
||||||
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
|
|
||||||
) {
|
|
||||||
return { claimRequestId, locked: true, message: 'Already locked by you' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
|
||||||
const lockAt = new Date();
|
|
||||||
const expiredAt = new Date(lockAt.getTime() + this.claimV2WorkflowLockTtlMs);
|
|
||||||
|
|
||||||
// Common lock fields written in both branches.
|
|
||||||
const baseLockUpdate: Record<string, unknown> = {
|
|
||||||
'workflow.locked': true,
|
|
||||||
'workflow.lockedAt': lockAt,
|
|
||||||
'workflow.expiredAt': expiredAt,
|
|
||||||
'workflow.lockedBy': {
|
|
||||||
actorId: new Types.ObjectId(actor.sub),
|
|
||||||
actorName: actor.fullName,
|
|
||||||
actorRole: 'damage_expert',
|
|
||||||
...(lockSnapshot && { expertProfileSnapshot: lockSnapshot }),
|
|
||||||
},
|
|
||||||
'workflow.preLockQueueSnapshot': {
|
|
||||||
claimStatus: claim.claimStatus,
|
|
||||||
currentStep: this.normalizeClaimWorkflowStepForSnapshot(
|
|
||||||
claim.workflow?.currentStep,
|
|
||||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
|
|
||||||
),
|
|
||||||
...(claim.workflow?.nextStep != null
|
|
||||||
? {
|
|
||||||
nextStep: this.normalizeClaimWorkflowStepForSnapshot(
|
|
||||||
claim.workflow.nextStep,
|
|
||||||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
$push: {
|
|
||||||
history: {
|
|
||||||
type: "CLAIM_LOCKED",
|
|
||||||
actor: {
|
|
||||||
actorId: new Types.ObjectId(actor.sub),
|
|
||||||
actorName: actor.fullName,
|
|
||||||
actorType: "damage_expert",
|
|
||||||
},
|
|
||||||
timestamp: new Date(),
|
|
||||||
metadata: {
|
|
||||||
note: isFactorValidationLock
|
|
||||||
? `Claim locked for factor validation by damage expert ${actor.fullName}`
|
|
||||||
: `Claim locked by damage expert ${actor.fullName}`,
|
|
||||||
...(isFactorValidationLock ? { phase: "factorValidation" } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Damage-review lock advances status/step; factor-validation lock keeps them
|
|
||||||
// intact so the claim remains in the factor-validation queue when the lock expires.
|
|
||||||
const update: Record<string, unknown> = isFactorValidationLock
|
|
||||||
? baseLockUpdate
|
|
||||||
: {
|
|
||||||
...baseLockUpdate,
|
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
|
||||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
|
||||||
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, update);
|
|
||||||
|
|
||||||
await this.recordClaimExpertActivity({
|
|
||||||
expertId: String(actor.sub),
|
|
||||||
tenantId: this.claimActivityTenantId(claim, actor),
|
|
||||||
claimId: String(claimRequestId),
|
|
||||||
eventType: ExpertFileActivityType.CHECKED,
|
|
||||||
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
|
|
||||||
if (ownerPhone) {
|
|
||||||
const expertLastName =
|
|
||||||
actor?.fullName?.trim()?.split(/\s+/).pop() || "کارشناس";
|
|
||||||
await this.smsOrchestrationService.sendThirdPartyExpertStartedReviewNotice(
|
|
||||||
{
|
|
||||||
receptor: ownerPhone,
|
|
||||||
fileKind: "claim",
|
|
||||||
publicId: claim.publicId,
|
|
||||||
expertLastName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
claimRequestId,
|
|
||||||
locked: true,
|
|
||||||
status: ClaimCaseStatus.EXPERT_REVIEWING,
|
|
||||||
claimStatus: ClaimStatus.UNDER_REVIEW,
|
|
||||||
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitResendDocsV2(
|
async submitResendDocsV2(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
|
import { Body, Controller, Get, Header, Param, Headers, Patch, Post, Put, UseGuards, Query, Res } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
@@ -15,6 +15,7 @@ import { RolesGuard } from "src/auth/guards/role.guard";
|
|||||||
import { Roles } from "src/decorators/roles.decorator";
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { ExpertFileAssignResultDto } from "src/common/dto/expert-file-assign-result.dto";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
import { ExpertClaimService } from "./expert-claim.service";
|
||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
|
import { GetClaimListV2ResponseDto } from "./dto/claim-list-v2.dto";
|
||||||
@@ -161,6 +162,29 @@ export class ExpertClaimV2Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("assign/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Check availability and assign claim to this damage expert",
|
||||||
|
description:
|
||||||
|
"Call before opening a case from the list. Returns `assigned` when the claim was free and is now locked to you, `already_assigned_to_you` when you already hold the lock, or **409** with `status=locked` when another expert is reviewing. Works for both the damage-review queue (`WAITING_FOR_DAMAGE_EXPERT`) and factor-validation queue.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiResponse({ status: 200, type: ExpertFileAssignResultDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: "Another expert is processing this claim",
|
||||||
|
type: ExpertFileAssignResultDto,
|
||||||
|
})
|
||||||
|
async assignClaimForReviewV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() actor,
|
||||||
|
) {
|
||||||
|
return await this.expertClaimService.assignClaimForReviewV2(
|
||||||
|
claimRequestId,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Put("lock/:claimRequestId")
|
@Put("lock/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Lock a claim request for review",
|
summary: "Lock a claim request for review",
|
||||||
@@ -168,7 +192,7 @@ export class ExpertClaimV2Controller {
|
|||||||
"Lockable in two queues:\n" +
|
"Lockable in two queues:\n" +
|
||||||
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
|
"1. **Damage review queue** — claim status `WAITING_FOR_DAMAGE_EXPERT`. Locking advances the claim to `status=EXPERT_REVIEWING`, `claimStatus=UNDER_REVIEW`, `workflow.currentStep=EXPERT_DAMAGE_ASSESSMENT`.\n" +
|
||||||
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
|
"2. **Factor validation queue** — claim status `EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `workflow.currentStep=EXPERT_COST_EVALUATION`. Locking only sets the workflow lock fields; status/claimStatus/currentStep are left untouched so the claim stays in the factor-validation queue when the lock expires.\n\n" +
|
||||||
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent.",
|
"Only one expert can hold the lock at a time. Re-locking by the same expert is idempotent. Same assignment logic as POST `assign/:claimRequestId`, but returns the legacy lock shape and maps conflicts to 400 instead of 409.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async lockClaimRequestV2(
|
async lockClaimRequestV2(
|
||||||
|
|||||||
@@ -100,3 +100,4 @@ export class BlameRequest {
|
|||||||
|
|
||||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||||
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);
|
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);
|
||||||
|
BlameRequestSchema.index({ status: 1, "workflow.locked": 1 });
|
||||||
Reference in New Issue
Block a user