forked from Yara724/api
Added Field Expert flows, and deactivated inquiry
This commit is contained in:
@@ -2880,6 +2880,141 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
|
||||
* The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY).
|
||||
*/
|
||||
async createClaimFromBlameForExpertV2(
|
||||
blameRequestId: string,
|
||||
expert: { sub: string; firstName?: string; lastName?: string },
|
||||
): Promise<CreateClaimFromBlameResponseDto> {
|
||||
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
|
||||
if (!blameRequest) {
|
||||
throw new NotFoundException("Blame request not found");
|
||||
}
|
||||
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!blameRequest.expertInitiated ||
|
||||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||||
!blameRequest.initiatedByFieldExpertId
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
|
||||
throw new ForbiddenException(
|
||||
"Only the field expert who created this blame file can create the claim.",
|
||||
);
|
||||
}
|
||||
const parties = blameRequest.parties || [];
|
||||
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
|
||||
let damagedUserId: string;
|
||||
if (isCarBody) {
|
||||
if (parties.length < 1) throw new BadRequestException("Blame request has no party");
|
||||
const first = parties.find((p) => p.role === "FIRST");
|
||||
if (!first?.person?.userId) throw new BadRequestException("First party has no userId");
|
||||
damagedUserId = String(first.person.userId);
|
||||
} else {
|
||||
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
|
||||
? String(blameRequest.expert.decision.guiltyPartyId)
|
||||
: null;
|
||||
if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set");
|
||||
const damagedParty = parties.find(
|
||||
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
|
||||
);
|
||||
if (!damagedParty?.person?.userId) {
|
||||
throw new BadRequestException("Could not determine damaged party");
|
||||
}
|
||||
damagedUserId = String(damagedParty.person.userId);
|
||||
}
|
||||
const existingClaim = await this.claimCaseDbService.findOne({
|
||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||
});
|
||||
if (existingClaim) {
|
||||
throw new ConflictException("A claim for this blame case already exists");
|
||||
}
|
||||
const claimNo = await this.generateUniqueClaimNumber();
|
||||
const damagedParty = parties.find(
|
||||
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
|
||||
);
|
||||
const newClaim = await this.claimCaseDbService.create({
|
||||
requestNo: claimNo,
|
||||
publicId: blameRequest.publicId,
|
||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||
blameRequestNo: blameRequest.requestNo,
|
||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
claimStatus: ClaimStatus.PENDING,
|
||||
workflow: {
|
||||
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
|
||||
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
|
||||
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
|
||||
locked: false,
|
||||
},
|
||||
owner: {
|
||||
userId: new Types.ObjectId(damagedUserId),
|
||||
userRole: damagedParty?.role as any,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
type: "CLAIM_CREATED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(expert.sub),
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
blameRequestId,
|
||||
blamePublicId: blameRequest.publicId,
|
||||
createdByExpert: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
this.logger.log(
|
||||
`Claim created by field expert: ${newClaim._id} from blame: ${blameRequestId}`,
|
||||
);
|
||||
return {
|
||||
claimRequestId: String(newClaim._id),
|
||||
publicId: blameRequest.publicId,
|
||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
message: "Claim created successfully. You can now fill claim data on behalf of the damaged party.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective user id for claim operations.
|
||||
* For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId.
|
||||
*/
|
||||
private async resolveClaimEffectiveUserId(
|
||||
claimCase: any,
|
||||
currentUserId: string,
|
||||
actorRole?: string,
|
||||
): Promise<string> {
|
||||
if (actorRole !== RoleEnum.FIELD_EXPERT || !claimCase?.blameRequestId) {
|
||||
return currentUserId;
|
||||
}
|
||||
const blameRequest = await this.blameRequestDbService.findById(
|
||||
claimCase.blameRequestId.toString(),
|
||||
);
|
||||
if (
|
||||
!blameRequest?.expertInitiated ||
|
||||
blameRequest.creationMethod !== "IN_PERSON" ||
|
||||
!blameRequest.initiatedByFieldExpertId
|
||||
) {
|
||||
return currentUserId;
|
||||
}
|
||||
if (String(blameRequest.initiatedByFieldExpertId) !== currentUserId) {
|
||||
return currentUserId;
|
||||
}
|
||||
return claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
|
||||
*
|
||||
@@ -2899,6 +3034,7 @@ export class ClaimRequestManagementService {
|
||||
claimRequestId: string,
|
||||
body: SelectOuterPartsV2Dto,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
try {
|
||||
// 1. Validate claim exists
|
||||
@@ -2910,8 +3046,13 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Validate user is the claim owner (damaged party)
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
// 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim)
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException(
|
||||
'Only the claim owner (damaged party) can select damaged parts'
|
||||
);
|
||||
@@ -3013,6 +3154,7 @@ export class ClaimRequestManagementService {
|
||||
claimRequestId: string,
|
||||
body: SelectOtherPartsV2Dto,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
try {
|
||||
// 1. Validate claim exists
|
||||
@@ -3024,8 +3166,13 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Validate user is the claim owner (damaged party)
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
// 2. Validate user is the claim owner (or field expert for IN_PERSON claim)
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException(
|
||||
'Only the claim owner (damaged party) can submit other parts and bank information'
|
||||
);
|
||||
@@ -3126,6 +3273,7 @@ export class ClaimRequestManagementService {
|
||||
async getCaptureRequirementsV2(
|
||||
claimRequestId: string,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
try {
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -3134,7 +3282,12 @@ export class ClaimRequestManagementService {
|
||||
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||
}
|
||||
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException('Only the claim owner can view capture requirements');
|
||||
}
|
||||
|
||||
@@ -3158,7 +3311,11 @@ export class ClaimRequestManagementService {
|
||||
return labels[key] || { fa: key, en: key };
|
||||
};
|
||||
|
||||
// Required documents
|
||||
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
|
||||
const blameRequest = claimCase.blameRequestId
|
||||
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
|
||||
: null;
|
||||
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
|
||||
const requiredDocsDefinition = [
|
||||
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' },
|
||||
{ key: 'damaged_driving_license_front', label_fa: 'گواهینامه طرف آسیبدیده - جلو', label_en: 'Damaged Party License - Front', category: 'damaged_party' },
|
||||
@@ -3168,11 +3325,13 @@ export class ClaimRequestManagementService {
|
||||
{ key: 'damaged_car_card_front', label_fa: 'کارت خودرو آسیبدیده - جلو', label_en: 'Damaged Car Card - Front', category: 'damaged_party' },
|
||||
{ key: 'damaged_car_card_back', label_fa: 'کارت خودرو آسیبدیده - پشت', label_en: 'Damaged Car Card - Back', category: 'damaged_party' },
|
||||
{ key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیبدیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
|
||||
...(isCarBody ? [] : [
|
||||
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
|
||||
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
|
||||
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
|
||||
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
|
||||
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
|
||||
]),
|
||||
];
|
||||
|
||||
const requiredDocuments = requiredDocsDefinition.map(doc => {
|
||||
@@ -3256,6 +3415,7 @@ export class ClaimRequestManagementService {
|
||||
body: UploadRequiredDocumentV2Dto,
|
||||
file: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
try {
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -3264,7 +3424,12 @@ export class ClaimRequestManagementService {
|
||||
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||
}
|
||||
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException('Only the claim owner can upload documents');
|
||||
}
|
||||
|
||||
@@ -3274,6 +3439,16 @@ export class ClaimRequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// CAR_BODY claims only allow 7 document types (no guilty_*)
|
||||
const blameForUpload = claimCase.blameRequestId
|
||||
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
|
||||
: null;
|
||||
const isCarBodyUpload = blameForUpload?.type === BlameRequestType.CAR_BODY;
|
||||
const guiltyKeys = ['guilty_driving_license_front', 'guilty_driving_license_back', 'guilty_car_card_front', 'guilty_car_card_back', 'guilty_metal_plate'];
|
||||
if (isCarBodyUpload && guiltyKeys.includes(body.documentKey)) {
|
||||
throw new BadRequestException(`Document type ${body.documentKey} is not required for CAR_BODY claims`);
|
||||
}
|
||||
|
||||
// Check if document already uploaded
|
||||
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
|
||||
if (existingDoc?.uploaded) {
|
||||
@@ -3318,11 +3493,11 @@ export class ClaimRequestManagementService {
|
||||
},
|
||||
};
|
||||
|
||||
// Check if all documents are uploaded
|
||||
const totalDocs = 13;
|
||||
// Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY)
|
||||
const totalDocsRequired = isCarBodyUpload ? 7 : 13;
|
||||
const currentDocs = claimCase.requiredDocuments || new Map();
|
||||
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
|
||||
const allDocumentsUploaded = uploadedCount >= totalDocs;
|
||||
const allDocumentsUploaded = uploadedCount >= totalDocsRequired;
|
||||
|
||||
// If all documents uploaded, move to next step
|
||||
if (allDocumentsUploaded) {
|
||||
@@ -3350,7 +3525,7 @@ export class ClaimRequestManagementService {
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
|
||||
|
||||
const remaining = totalDocs - uploadedCount;
|
||||
const remaining = totalDocsRequired - uploadedCount;
|
||||
const message = allDocumentsUploaded
|
||||
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
|
||||
: `Document uploaded successfully. ${remaining} documents remaining.`;
|
||||
@@ -3378,6 +3553,7 @@ export class ClaimRequestManagementService {
|
||||
body: CapturePartV2Dto,
|
||||
file: Express.Multer.File,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
try {
|
||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -3386,7 +3562,12 @@ export class ClaimRequestManagementService {
|
||||
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
|
||||
}
|
||||
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claimCase,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException('Only the claim owner can capture parts');
|
||||
}
|
||||
|
||||
@@ -3498,14 +3679,42 @@ export class ClaimRequestManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Get list of claims for current user
|
||||
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
|
||||
*/
|
||||
async getMyClaimsV2(currentUserId: string): Promise<GetMyClaimsV2ResponseDto> {
|
||||
async getMyClaimsV2(
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<GetMyClaimsV2ResponseDto> {
|
||||
try {
|
||||
const claims = await this.claimCaseDbService.find(
|
||||
let claims: any[];
|
||||
if (actor?.role === RoleEnum.FIELD_EXPERT) {
|
||||
const expertBlameIds = await this.blameRequestDbService
|
||||
.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
creationMethod: 'IN_PERSON',
|
||||
initiatedByFieldExpertId: new Types.ObjectId(currentUserId),
|
||||
},
|
||||
{ select: '_id', lean: true },
|
||||
)
|
||||
.then((docs) => docs.map((d) => (d as any)._id));
|
||||
claims = await this.claimCaseDbService.find(
|
||||
{
|
||||
$or: [
|
||||
{ 'owner.userId': new Types.ObjectId(currentUserId) },
|
||||
...(expertBlameIds.length
|
||||
? [{ blameRequestId: { $in: expertBlameIds } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ lean: true },
|
||||
);
|
||||
} else {
|
||||
claims = await this.claimCaseDbService.find(
|
||||
{ 'owner.userId': new Types.ObjectId(currentUserId) },
|
||||
{ lean: true },
|
||||
);
|
||||
}
|
||||
const list = (claims as any[]).map((c) => ({
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
@@ -3530,13 +3739,19 @@ export class ClaimRequestManagementService {
|
||||
async getClaimDetailsV2(
|
||||
claimRequestId: string,
|
||||
currentUserId: string,
|
||||
actor?: { sub: string; role?: string },
|
||||
): Promise<ClaimDetailsV2ResponseDto> {
|
||||
try {
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
if (!claim) {
|
||||
throw new NotFoundException('Claim request not found');
|
||||
}
|
||||
if (!claim.owner?.userId || claim.owner.userId.toString() !== currentUserId) {
|
||||
const effectiveUserId = await this.resolveClaimEffectiveUserId(
|
||||
claim,
|
||||
currentUserId,
|
||||
actor?.role,
|
||||
);
|
||||
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
|
||||
throw new ForbiddenException('You do not have access to this claim');
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
@Controller("v2/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.USER)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
@@ -51,7 +51,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -91,6 +91,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
return await this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -231,6 +232,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -345,6 +347,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -397,6 +400,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -434,16 +438,16 @@ Returns status of each item (uploaded/captured or not).
|
||||
description: `
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
||||
|
||||
**Upload one of the 13 required documents:**
|
||||
**Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||
- car_green_card
|
||||
- damaged_driving_license_front/back
|
||||
- damaged_chassis_number, damaged_engine_photo
|
||||
- damaged_car_card_front/back, damaged_metal_plate
|
||||
- guilty_driving_license_front/back
|
||||
- guilty_car_card_front/back, guilty_metal_plate
|
||||
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||
|
||||
**When all 13 documents are uploaded:**
|
||||
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
|
||||
**When all required documents are uploaded:** Workflow moves to CAPTURE_PART_DAMAGES (Step 5).
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -507,6 +511,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -547,6 +552,8 @@ Returns status of each item (uploaded/captured or not).
|
||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||
|
||||
**All captures must be completed before user can submit claim.**
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -598,6 +605,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
|
||||
@@ -198,7 +198,18 @@ export class ExpertBlameService {
|
||||
// Filter to show only:
|
||||
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
||||
// 2. Requests decided by current expert
|
||||
// 3. Expert-initiated: only the initiating field expert sees them
|
||||
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
|
||||
const expertInitiated = doc.expertInitiated === true;
|
||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||
|
||||
if (expertInitiated && initiatedByFieldExpertId) {
|
||||
if (String(initiatedByFieldExpertId) !== expertId) {
|
||||
return false; // Only the initiating field expert can see this file
|
||||
}
|
||||
return true; // Initiating expert can see their expert-initiated file
|
||||
}
|
||||
|
||||
const status = doc.status as string;
|
||||
const decision = doc.expert as any;
|
||||
const decidedByExpertId = decision?.decision?.decidedByExpertId;
|
||||
@@ -522,13 +533,20 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
// Access control: Check if expert has permission to view this request
|
||||
const decision = (doc.expert as any)?.decision;
|
||||
const decidedByExpertId = decision?.decidedByExpertId;
|
||||
// Access control: Expert-initiated files only visible to the initiating field expert
|
||||
const expertInitiated = doc.expertInitiated === true;
|
||||
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
"Only the field expert who created this file can view and review it.",
|
||||
);
|
||||
}
|
||||
|
||||
// Allow if:
|
||||
// 1. No decision yet (fresh request)
|
||||
// 2. Decision made by current expert
|
||||
const decision = (doc.expert as any)?.decision;
|
||||
const decidedByExpertId = decision?.decidedByExpertId;
|
||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
"You do not have permission to view this request. It has been handled by another expert.",
|
||||
@@ -660,6 +678,17 @@ export class ExpertBlameService {
|
||||
);
|
||||
}
|
||||
|
||||
// Expert-initiated: only the initiating field expert can lock and review
|
||||
if (
|
||||
request.expertInitiated &&
|
||||
request.initiatedByFieldExpertId &&
|
||||
String(request.initiatedByFieldExpertId) !== actorDetail.sub
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"Only the field expert who created this file can lock and review it.",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if locked and not expired
|
||||
if (request.workflow?.locked) {
|
||||
const lockedAt = request.workflow.lockedAt;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
||||
@Controller("v2/expert-blame")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||
export class ExpertBlameV2Controller {
|
||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert uploading a party's signature for expert-initiated IN_PERSON blame.
|
||||
*/
|
||||
export class ExpertUploadPartySignatureDto {
|
||||
@ApiProperty({
|
||||
enum: ["FIRST", "SECOND"],
|
||||
description: "Which party's signature is being uploaded. CAR_BODY: use FIRST only. THIRD_PARTY: FIRST and SECOND.",
|
||||
})
|
||||
partyRole: "FIRST" | "SECOND";
|
||||
|
||||
@ApiProperty({
|
||||
default: true,
|
||||
description: "Party accepts the blame agreement (true). Set false if party rejects.",
|
||||
})
|
||||
isAccept: boolean;
|
||||
}
|
||||
19
src/request-management/dto/send-party-otps.dto.ts
Normal file
19
src/request-management/dto/send-party-otps.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert to request OTP send to one or two parties for expert-initiated IN_PERSON blame.
|
||||
* Uses the same SMS flow as /user/send-otp. After this, parties receive SMS with OTP; expert collects and calls verify-party-otps.
|
||||
*/
|
||||
export class SendPartyOtpsDto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number (e.g. 09123456789). SMS with OTP will be sent to this number.",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Second party phone number. Required when blame type is THIRD_PARTY. SMS with OTP will be sent to this number.",
|
||||
example: "09187654321",
|
||||
})
|
||||
secondPartyPhoneNumber?: string;
|
||||
}
|
||||
31
src/request-management/dto/verify-party-otps.dto.ts
Normal file
31
src/request-management/dto/verify-party-otps.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert to verify one or two party OTPs for expert-initiated IN_PERSON blame.
|
||||
* First party must verify; second party required only when type is THIRD_PARTY.
|
||||
*/
|
||||
export class VerifyPartyOtpsDto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number (must have requested OTP via /user/send-otp)",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "OTP received by first party",
|
||||
example: "258567",
|
||||
})
|
||||
firstPartyOtp: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Second party phone number. Required when blame type is THIRD_PARTY.",
|
||||
example: "09187654321",
|
||||
})
|
||||
secondPartyPhoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "OTP received by second party. Required when secondPartyPhoneNumber is provided.",
|
||||
example: "123456",
|
||||
})
|
||||
secondPartyOtp?: string;
|
||||
}
|
||||
@@ -31,7 +31,11 @@ import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-par
|
||||
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto";
|
||||
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
|
||||
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
||||
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
|
||||
/**
|
||||
* V2 expert-initiated blame API: uses BlameRequest (workflow) model.
|
||||
@@ -80,7 +84,51 @@ export class ExpertInitiatedV2Controller {
|
||||
description:
|
||||
"Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.",
|
||||
})
|
||||
@ApiBody({ type: CreateExpertInitiatedFileDto })
|
||||
@ApiBody({
|
||||
type: CreateExpertInitiatedFileDto,
|
||||
examples: {
|
||||
thirdPartyInPerson: {
|
||||
summary: "Third-party, IN_PERSON (both parties)",
|
||||
description:
|
||||
"Expert fills form on-site for both parties.",
|
||||
value: {
|
||||
type: "THIRD_PARTY",
|
||||
creationMethod: "IN_PERSON",
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
secondPartyPhoneNumber: "09187654321",
|
||||
},
|
||||
},
|
||||
thirdPartyLink: {
|
||||
summary: "Third-party, LINK",
|
||||
description:
|
||||
"Expert sends link to first and second party by phone.",
|
||||
value: {
|
||||
type: "THIRD_PARTY",
|
||||
creationMethod: "LINK",
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
},
|
||||
},
|
||||
carBodyInPerson: {
|
||||
summary: "Car-body, IN_PERSON",
|
||||
description: "Expert fills form on-site for single party (car body).",
|
||||
value: {
|
||||
type: "CAR_BODY",
|
||||
creationMethod: "IN_PERSON",
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
},
|
||||
},
|
||||
carBodyLink: {
|
||||
summary: "Car-body, LINK",
|
||||
description:
|
||||
"Expert sends link to party by phone. Only first party phone needed.",
|
||||
value: {
|
||||
type: "CAR_BODY",
|
||||
creationMethod: "LINK",
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "File created",
|
||||
@@ -103,18 +151,173 @@ export class ExpertInitiatedV2Controller {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("send-link/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Send blame link to party/parties (LINK)",
|
||||
description:
|
||||
"For expert-initiated LINK files only. Sends the blame link to the first party (and second party for THIRD_PARTY). SMS delivery is mocked for now; first party opens the link and fills the form via the normal flow. Call after create when creationMethod is LINK.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Link sent (mocked); recipients can open the link to fill the form",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sent: { type: "boolean" },
|
||||
linkUrl: { type: "string" },
|
||||
sentTo: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
phoneNumber: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async sendLinkV2(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("requestId") requestId: string,
|
||||
) {
|
||||
return this.requestManagementService.sendLinkV2(expert, requestId);
|
||||
}
|
||||
|
||||
@Post("send-party-otps/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Send OTP to party/parties (IN_PERSON)",
|
||||
description:
|
||||
"Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiBody({ type: SendPartyOtpsDto })
|
||||
@ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" })
|
||||
async sendPartyOtpsV2(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: SendPartyOtpsDto,
|
||||
) {
|
||||
return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
|
||||
}
|
||||
|
||||
@Post("verify-party-otps/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Verify party OTPs (IN_PERSON)",
|
||||
description:
|
||||
"After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiBody({ type: VerifyPartyOtpsDto })
|
||||
@ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" })
|
||||
async verifyPartyOtpsV2(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: VerifyPartyOtpsDto,
|
||||
) {
|
||||
return this.requestManagementService.verifyPartyOtpsV2(expert, requestId, dto);
|
||||
}
|
||||
|
||||
@Post("complete-blame-data/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Submit all blame data (IN_PERSON)",
|
||||
description:
|
||||
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form; completes the BlameRequest.",
|
||||
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form. After this, status becomes WAITING_FOR_SIGNATURES; use upload-party-signature to collect party signatures.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiBody({
|
||||
description:
|
||||
"ExpertCompleteThirdPartyFormDto for THIRD_PARTY or ExpertCompleteCarBodyFormDto for CAR_BODY",
|
||||
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. All nested fields are listed so you can fill or test without guessing property names.",
|
||||
examples: {
|
||||
THIRD_PARTY: {
|
||||
summary: "THIRD_PARTY",
|
||||
description: "Use when the blame file type is THIRD_PARTY",
|
||||
value: {
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
firstPartyInitialForm: {
|
||||
expertOpinion: false,
|
||||
imDamaged: false,
|
||||
imGuilty: true,
|
||||
},
|
||||
firstPartyPlate: {
|
||||
nationalCodeOfInsurer: "",
|
||||
nationalCodeOfDriver: "",
|
||||
insurerLicense: "",
|
||||
driverLicense: "",
|
||||
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
|
||||
driverIsInsurer: true,
|
||||
isNewCar: false,
|
||||
userNoCertificate: false,
|
||||
insurerBirthday: 13770624,
|
||||
driverBirthday: "1370-01-01",
|
||||
},
|
||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
||||
firstPartyDescription: { desc: "توضیح حادثه طرف اول" },
|
||||
secondParty: {
|
||||
phoneNumber: "09187654321",
|
||||
initialForm: {
|
||||
expertOpinion: false,
|
||||
imDamaged: true,
|
||||
imGuilty: false,
|
||||
},
|
||||
plate: {
|
||||
nationalCodeOfInsurer: "",
|
||||
nationalCodeOfDriver: "",
|
||||
insurerLicense: "",
|
||||
driverLicense: "",
|
||||
plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 },
|
||||
driverIsInsurer: true,
|
||||
isNewCar: false,
|
||||
userNoCertificate: false,
|
||||
insurerBirthday: 13700720,
|
||||
driverBirthday: "1370-01-01",
|
||||
},
|
||||
location: { lat: 35.6892, lon: 51.389 },
|
||||
description: { desc: "توضیح حادثه طرف دوم" },
|
||||
},
|
||||
guiltyPartyPhoneNumber: "09123456789",
|
||||
},
|
||||
},
|
||||
CAR_BODY: {
|
||||
summary: "CAR_BODY",
|
||||
description: "Use when the blame file type is CAR_BODY",
|
||||
value: {
|
||||
firstPartyPhoneNumber: "09123456789",
|
||||
firstPartyInitialForm: {
|
||||
expertOpinion: false,
|
||||
imDamaged: false,
|
||||
imGuilty: true,
|
||||
},
|
||||
carBodyForm: { car: true, object: false },
|
||||
firstPartyPlate: {
|
||||
plateId: "",
|
||||
nationalCodeOfInsurer: "",
|
||||
nationalCodeOfDriver: "",
|
||||
insurerLicense: "",
|
||||
driverLicense: "",
|
||||
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
|
||||
driverIsInsurer: true,
|
||||
isNewCar: false,
|
||||
userNoCertificate: false,
|
||||
insurerBirthday: 1370,
|
||||
driverBirthday: "1370-01-01",
|
||||
},
|
||||
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
|
||||
firstPartyDescription: {
|
||||
desc: "توضیح حادثه",
|
||||
accidentDate: "2025-01-15",
|
||||
accidentTime: "14:30",
|
||||
weatherCondition: "صاف",
|
||||
roadCondition: "خشک",
|
||||
lightCondition: "روز",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: { type: "object" },
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Blame form completed" })
|
||||
@ApiResponse({ status: 200, description: "Blame form completed; next: upload party signature(s)" })
|
||||
async completeBlameDataV2(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@@ -223,6 +426,76 @@ export class ExpertInitiatedV2Controller {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("upload-party-signature/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Expert uploads party signature (IN_PERSON)",
|
||||
description:
|
||||
"For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["partyRole", "sign"],
|
||||
properties: {
|
||||
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
isAccept: { type: "boolean", default: true },
|
||||
sign: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `expert-party-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiResponse({ status: 200, description: "Signature recorded" })
|
||||
async uploadPartySignatureV2(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||
@UploadedFile() sign?: Express.Multer.File,
|
||||
) {
|
||||
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
|
||||
? body.partyRole
|
||||
: ("FIRST" as const);
|
||||
const isAccept = body.isAccept === false || body.isAccept === "false" ? false : true;
|
||||
return this.requestManagementService.expertUploadPartySignatureV2(
|
||||
expert,
|
||||
requestId,
|
||||
partyRole as PartyRole,
|
||||
isAccept,
|
||||
sign!,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("create-claim-from-blame/:blameRequestId")
|
||||
@ApiOperation({
|
||||
summary: "[V2] Create claim from expert-initiated IN_PERSON blame",
|
||||
description:
|
||||
"Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.",
|
||||
})
|
||||
@ApiParam({ name: "blameRequestId", description: "Completed blame request ID" })
|
||||
@ApiResponse({ status: 201, description: "Claim created" })
|
||||
async createClaimFromBlame(
|
||||
@CurrentUser() expert: any,
|
||||
@Param("blameRequestId") blameRequestId: string,
|
||||
) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
|
||||
blameRequestId,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("complete-claim-data/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit claim-needed data (expert-initiated IN_PERSON)",
|
||||
|
||||
@@ -12,7 +12,9 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { CronModule } from "src/utils/cron/cron.module";
|
||||
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
|
||||
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
|
||||
import { UserSignDbService } from "./entities/db-service/sign.db.service";
|
||||
@@ -44,6 +46,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
||||
SandHubModule,
|
||||
SmsManagerModule,
|
||||
PublicIdModule,
|
||||
HashModule,
|
||||
WorkflowStepManagementModule,
|
||||
PlatesModule,
|
||||
MulterModule.register({
|
||||
@@ -59,6 +62,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
||||
]),
|
||||
forwardRef(() => ClaimRequestManagementModule),
|
||||
CronModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [
|
||||
RequestManagementController,
|
||||
|
||||
@@ -65,6 +65,8 @@ import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/
|
||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { UploadContext } from "./entities/schema/blame-voice.schema";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class RequestManagementService {
|
||||
@@ -78,6 +80,14 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert plate (string or { ir, leftDigits, centerAlphabet, centerDigits }) to string for vehicle.plateId */
|
||||
private plateToPlateIdString(plate: any): string {
|
||||
if (plate == null) return "";
|
||||
if (typeof plate === "string") return plate;
|
||||
const p = plate as { ir?: number; leftDigits?: number; centerAlphabet?: string; centerDigits?: number };
|
||||
return [p.ir, p.leftDigits, p.centerAlphabet, p.centerDigits].filter((x) => x != null).join("-");
|
||||
}
|
||||
|
||||
private getPartyIndex(req: any, role: PartyRole): number {
|
||||
return Array.isArray(req.parties)
|
||||
? req.parties.findIndex((p) => p?.role === role)
|
||||
@@ -292,6 +302,8 @@ export class RequestManagementService {
|
||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||
private readonly publicIdService: PublicIdService,
|
||||
private readonly workflowStepDbService: WorkflowStepDbService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly userAuthService: UserAuthService,
|
||||
) {}
|
||||
|
||||
async createRequestV2(user: any, type: BlameRequestType) {
|
||||
@@ -738,12 +750,12 @@ export class RequestManagementService {
|
||||
let inquiryRaw: any;
|
||||
let inquiryMapped: any;
|
||||
try {
|
||||
const inquiry = await this.sandHubService.getTejaratBlockInquiry({
|
||||
plate: body.plate,
|
||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
});
|
||||
inquiryRaw = inquiry.raw;
|
||||
inquiryMapped = inquiry.mapped;
|
||||
// const inquiry = await this.sandHubService.getTejaratBlockInquiry({
|
||||
// plate: body.plate,
|
||||
// nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
// });
|
||||
// inquiryRaw = inquiry.raw;
|
||||
// inquiryMapped = inquiry.mapped;
|
||||
this.logger.log(
|
||||
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
|
||||
);
|
||||
@@ -808,7 +820,10 @@ export class RequestManagementService {
|
||||
|
||||
if (!party.vehicle) party.vehicle = {} as any;
|
||||
party.vehicle.isNew = body.isNewCar;
|
||||
party.vehicle.plateId = body.plateId;
|
||||
party.vehicle.plateId =
|
||||
typeof body.plateId === "string"
|
||||
? body.plateId
|
||||
: this.plateToPlateIdString(body.plate);
|
||||
party.vehicle.name = inquiryMapped?.MapTypNam;
|
||||
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
|
||||
party.vehicle.inquiry = {
|
||||
@@ -1140,9 +1155,53 @@ export class RequestManagementService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if second party already exists
|
||||
// Check if second party already exists (e.g. expert-initiated LINK: expert already added both parties)
|
||||
const secondPartyIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
if (secondPartyIdx !== -1) {
|
||||
if (
|
||||
req.expertInitiated &&
|
||||
req.creationMethod === CreationMethod.LINK
|
||||
) {
|
||||
// Just advance workflow; second party already in parties. Optionally resend SMS.
|
||||
const secondPartyPhone =
|
||||
(req.parties[secondPartyIdx]?.person as any)?.phoneNumber || phoneNumber;
|
||||
req.status = CaseStatus.WAITING_FOR_SECOND_PARTY;
|
||||
await this.advanceWorkflowToNext(req, stepKey);
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "SECOND_PARTY_INVITED",
|
||||
actor: {
|
||||
actorId: Types.ObjectId.isValid(user?.sub)
|
||||
? new Types.ObjectId(user.sub)
|
||||
: undefined,
|
||||
actorName: user?.fullName,
|
||||
actorType: "user",
|
||||
},
|
||||
metadata: { secondPartyPhone, expertInitiatedLink: true },
|
||||
} as any);
|
||||
await (req as any).save();
|
||||
const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
||||
try {
|
||||
await this.smsManagerService.verifyLookUp({
|
||||
token: url,
|
||||
template: "yara724-invite-link",
|
||||
receptor: secondPartyPhone,
|
||||
});
|
||||
this.logger.log(
|
||||
`[SMS] Invitation resent to ${secondPartyPhone} for expert-initiated request ${req.publicId}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[SMS] Failed to send invitation to ${secondPartyPhone}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
requestId: req._id,
|
||||
publicId: req.publicId,
|
||||
workflow: req.workflow,
|
||||
invitationUrl: url,
|
||||
};
|
||||
}
|
||||
throw new ConflictException("Second party already added to this request");
|
||||
}
|
||||
|
||||
@@ -1602,47 +1661,48 @@ export class RequestManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Activate
|
||||
// --- Proceed with existing logic if the check passes ---
|
||||
// 1) Main third-party/block inquiry (existing behavior)
|
||||
const sanHubResponse = await this.sandHubService.getSandHubResponse({
|
||||
plate: body.plate,
|
||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
});
|
||||
// const sanHubResponse = await this.sandHubService.getSandHubResponse({
|
||||
// plate: body.plate,
|
||||
// nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
// });
|
||||
|
||||
if (sanHubResponse.Error) {
|
||||
throw new HttpException(
|
||||
sanHubResponse.Error.Message,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// if (sanHubResponse.Error) {
|
||||
// throw new HttpException(
|
||||
// sanHubResponse.Error.Message,
|
||||
// HttpStatus.BAD_REQUEST,
|
||||
// );
|
||||
// }
|
||||
|
||||
// 2) Personal inquiry (new shared SandHub API)
|
||||
// This is a best-effort call; if it fails we log and continue.
|
||||
try {
|
||||
const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
||||
body.nationalCodeOfInsurer,
|
||||
body.insurerBirthday,
|
||||
);
|
||||
this.logger.log(
|
||||
`[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify(
|
||||
personalInquiry,
|
||||
)}`,
|
||||
);
|
||||
// NOTE: We are not persisting this data yet; once the exact fields
|
||||
// are finalized, we can map and store them on the request document.
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
// try {
|
||||
// const personalInquiry = await this.sandHubService.getPersonalInquiry(
|
||||
// body.nationalCodeOfInsurer,
|
||||
// body.insurerBirthday,
|
||||
// );
|
||||
// this.logger.log(
|
||||
// `[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify(
|
||||
// personalInquiry,
|
||||
// )}`,
|
||||
// );
|
||||
// // NOTE: We are not persisting this data yet; once the exact fields
|
||||
// // are finalized, we can map and store them on the request document.
|
||||
// } catch (err) {
|
||||
// this.logger.error(
|
||||
// `[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`,
|
||||
// );
|
||||
// }
|
||||
|
||||
return await this.addPlate(
|
||||
sanHubResponse,
|
||||
request,
|
||||
user,
|
||||
body,
|
||||
isFirstParty ? "firstParty" : "secondParty",
|
||||
);
|
||||
// return await this.addPlate(
|
||||
// sanHubResponse,
|
||||
// request,
|
||||
// user,
|
||||
// body,
|
||||
// isFirstParty ? "firstParty" : "secondParty",
|
||||
// );
|
||||
}
|
||||
|
||||
async carBodyFormStep(
|
||||
@@ -3463,6 +3523,212 @@ export class RequestManagementService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Send blame link to first party (and second party for THIRD_PARTY) for expert-initiated LINK method.
|
||||
* SMS delivery is mocked for now; replace with real SMS provider later. First party opens the link and fills the form via normal v2 flow.
|
||||
*/
|
||||
async sendLinkV2(
|
||||
expert: any,
|
||||
requestId: string,
|
||||
): Promise<{
|
||||
sent: boolean;
|
||||
linkUrl: string;
|
||||
sentTo: { role: string; phoneNumber: string }[];
|
||||
}> {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, expert);
|
||||
|
||||
const baseUrl =
|
||||
process.env.FRONTEND_BASE_URL || process.env.APP_URL || "";
|
||||
const linkUrl = baseUrl
|
||||
? `${baseUrl.replace(/\/$/, "")}/blame/${requestId}`
|
||||
: "";
|
||||
|
||||
const sentTo: { role: string; phoneNumber: string }[] = [];
|
||||
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||
if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) {
|
||||
const phone = req.parties[firstIdx].person.phoneNumber;
|
||||
this.logger.log(
|
||||
`[MOCK SMS] Would send link to first party ${phone}: ${linkUrl}`,
|
||||
);
|
||||
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone });
|
||||
}
|
||||
if (req.type === BlameRequestType.THIRD_PARTY) {
|
||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
||||
if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) {
|
||||
const phone = req.parties[secondIdx].person.phoneNumber;
|
||||
this.logger.log(
|
||||
`[MOCK SMS] Would send link to second party ${phone}: ${linkUrl}`,
|
||||
);
|
||||
sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone });
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray((req as any).history)) (req as any).history = [];
|
||||
(req as any).history.push({
|
||||
type: "LINK_SENT",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(expert.sub),
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
metadata: { linkUrl, sentTo },
|
||||
});
|
||||
await (req as any).save();
|
||||
|
||||
return { sent: true, linkUrl, sentTo };
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Send OTP via SMS to one or two parties for expert-initiated IN_PERSON blame.
|
||||
* Uses the same flow as /user/send-otp (same template, expiry). After this, parties receive SMS; expert collects OTPs and calls verify-party-otps.
|
||||
*/
|
||||
async sendPartyOtpsV2(
|
||||
expert: any,
|
||||
requestId: string,
|
||||
dto: { firstPartyPhoneNumber: string; secondPartyPhoneNumber?: string },
|
||||
): Promise<{ sent: boolean; message: string }> {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, expert);
|
||||
if (req.type === BlameRequestType.THIRD_PARTY && !dto.secondPartyPhoneNumber) {
|
||||
throw new BadRequestException(
|
||||
"Second party phone number is required for THIRD_PARTY. Provide secondPartyPhoneNumber to send OTP to both parties.",
|
||||
);
|
||||
}
|
||||
const sent: string[] = [];
|
||||
try {
|
||||
await this.userAuthService.sendOtpRequest(dto.firstPartyPhoneNumber);
|
||||
sent.push(dto.firstPartyPhoneNumber);
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes("Wait for expiry")) {
|
||||
throw new BadRequestException(
|
||||
`First party (${dto.firstPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (dto.secondPartyPhoneNumber) {
|
||||
try {
|
||||
await this.userAuthService.sendOtpRequest(dto.secondPartyPhoneNumber);
|
||||
sent.push(dto.secondPartyPhoneNumber);
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes("Wait for expiry")) {
|
||||
throw new BadRequestException(
|
||||
`Second party (${dto.secondPartyPhoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sent: true,
|
||||
message:
|
||||
sent.length === 2
|
||||
? `OTP sent to both parties (${sent.join(", ")}). Have them tell you the code, then call verify-party-otps.`
|
||||
: `OTP sent to ${sent[0]}. Have the party tell you the code, then call verify-party-otps.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Verify one or two party OTPs for expert-initiated IN_PERSON blame.
|
||||
* Call this after send-party-otps (or after parties requested OTP via /user/send-otp). On success, parties are linked to their user ids so the expert can proceed with complete-blame-data.
|
||||
*/
|
||||
async verifyPartyOtpsV2(
|
||||
expert: any,
|
||||
requestId: string,
|
||||
dto: {
|
||||
firstPartyPhoneNumber: string;
|
||||
firstPartyOtp: string;
|
||||
secondPartyPhoneNumber?: string;
|
||||
secondPartyOtp?: string;
|
||||
},
|
||||
): Promise<{ verified: boolean; message: string }> {
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, expert);
|
||||
|
||||
const now = Date.now();
|
||||
const verifyOne = async (phone: string, otp: string): Promise<Types.ObjectId> => {
|
||||
const user = await this.userDbService.findOne({
|
||||
$or: [{ username: phone }, { mobile: phone }],
|
||||
});
|
||||
if (!user) throw new BadRequestException(`User not found for phone: ${phone}`);
|
||||
const u = user as any;
|
||||
if (u.otp == null) throw new BadRequestException(`No OTP requested for ${phone}. User must call /user/send-otp first.`);
|
||||
if (u.otpExpire < now) throw new BadRequestException(`OTP expired for ${phone}. User must request a new OTP.`);
|
||||
const valid = await this.hashService.compare(otp, u.otp);
|
||||
if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`);
|
||||
return u._id;
|
||||
};
|
||||
|
||||
const firstUserId = await verifyOne(dto.firstPartyPhoneNumber, dto.firstPartyOtp);
|
||||
if (!Array.isArray(req.parties)) req.parties = [];
|
||||
const firstIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.FIRST);
|
||||
if (firstIdx === -1) throw new BadRequestException("First party not found on request");
|
||||
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||||
req.parties[firstIdx].person.userId = firstUserId;
|
||||
req.parties[firstIdx].person.phoneNumber = dto.firstPartyPhoneNumber;
|
||||
|
||||
if (req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber && dto.secondPartyOtp) {
|
||||
const secondUserId = await verifyOne(dto.secondPartyPhoneNumber, dto.secondPartyOtp);
|
||||
let secondIdx = req.parties.findIndex((p: any) => p?.role === PartyRole.SECOND);
|
||||
if (secondIdx === -1) {
|
||||
req.parties.push({
|
||||
role: PartyRole.SECOND,
|
||||
person: {
|
||||
userId: secondUserId,
|
||||
phoneNumber: dto.secondPartyPhoneNumber,
|
||||
},
|
||||
} as any);
|
||||
} else {
|
||||
if (!req.parties[secondIdx].person) req.parties[secondIdx].person = {} as any;
|
||||
req.parties[secondIdx].person.userId = secondUserId;
|
||||
req.parties[secondIdx].person.phoneNumber = dto.secondPartyPhoneNumber;
|
||||
}
|
||||
} else if (req.type === BlameRequestType.THIRD_PARTY && (dto.secondPartyPhoneNumber || dto.secondPartyOtp)) {
|
||||
throw new BadRequestException("For THIRD_PARTY both secondPartyPhoneNumber and secondPartyOtp are required.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.history)) req.history = [];
|
||||
req.history.push({
|
||||
type: "PARTY_OTPS_VERIFIED",
|
||||
actor: {
|
||||
actorId: new Types.ObjectId(expert.sub),
|
||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||
actorType: "field_expert",
|
||||
},
|
||||
metadata: {
|
||||
firstPartyVerified: true,
|
||||
secondPartyVerified: req.type === BlameRequestType.THIRD_PARTY && !!dto.secondPartyPhoneNumber,
|
||||
},
|
||||
} as any);
|
||||
await (req as any).save();
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
message: req.type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber
|
||||
? "Both parties verified. You can proceed to fill the blame form."
|
||||
: "First party verified. You can proceed to fill the blame form.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all expert-initiated blame files for the current field expert (their panel).
|
||||
* Only files where initiatedBy === current user are returned.
|
||||
@@ -3684,7 +3950,7 @@ export class RequestManagementService {
|
||||
driverBirthday: firstPartyPlate.driverBirthday,
|
||||
},
|
||||
vehicle: {
|
||||
plateId: firstPartyPlate.plate,
|
||||
plateId: this.plateToPlateIdString(firstPartyPlate.plate) || (firstPartyPlate as any).plateId,
|
||||
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||||
model: sandHubReport?.MapTypNam,
|
||||
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||||
@@ -3723,7 +3989,7 @@ export class RequestManagementService {
|
||||
|
||||
req.parties = [firstParty];
|
||||
req.workflow = {
|
||||
currentStep: WorkflowStep.COMPLETED,
|
||||
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||
nextStep: undefined,
|
||||
completedSteps: [
|
||||
WorkflowStep.CREATED,
|
||||
@@ -3736,7 +4002,7 @@ export class RequestManagementService {
|
||||
].filter((s) => s),
|
||||
locked: false,
|
||||
};
|
||||
req.status = CaseStatus.COMPLETED;
|
||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||
req.blameStatus = BlameStatus.AGREED;
|
||||
(req as any).filledBy = FilledBy.EXPERT;
|
||||
|
||||
@@ -3827,7 +4093,7 @@ export class RequestManagementService {
|
||||
driverBirthday: plateDto.driverBirthday,
|
||||
},
|
||||
vehicle: {
|
||||
plateId: plateDto.plate,
|
||||
plateId: this.plateToPlateIdString(plateDto.plate) || (plateDto as any).plateId,
|
||||
name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
|
||||
model: sandHubReport?.MapTypNam,
|
||||
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
|
||||
@@ -3867,8 +4133,14 @@ export class RequestManagementService {
|
||||
);
|
||||
|
||||
req.parties = [firstParty, secondParty];
|
||||
const guiltyPartyId =
|
||||
formData.guiltyPartyPhoneNumber === formData.firstPartyPhoneNumber
|
||||
? String(firstPartyUserId)
|
||||
: String(secondPartyUserId);
|
||||
if (!req.expert) req.expert = {} as any;
|
||||
req.expert.decision = { guiltyPartyId: new Types.ObjectId(guiltyPartyId) } as any;
|
||||
req.workflow = {
|
||||
currentStep: WorkflowStep.COMPLETED,
|
||||
currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||
nextStep: undefined,
|
||||
completedSteps: [
|
||||
WorkflowStep.CREATED,
|
||||
@@ -3885,7 +4157,7 @@ export class RequestManagementService {
|
||||
].filter((s) => s),
|
||||
locked: false,
|
||||
};
|
||||
req.status = CaseStatus.COMPLETED;
|
||||
req.status = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||
req.blameStatus = BlameStatus.AGREED;
|
||||
(req as any).filledBy = FilledBy.EXPERT;
|
||||
|
||||
@@ -4006,6 +4278,116 @@ export class RequestManagementService {
|
||||
return { requestId: req._id };
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Expert uploads a party's signature for expert-initiated IN_PERSON blame.
|
||||
* CAR_BODY: upload FIRST only. THIRD_PARTY: upload FIRST then SECOND.
|
||||
* When all required parties have signed, status becomes COMPLETED.
|
||||
*/
|
||||
async expertUploadPartySignatureV2(
|
||||
expert: any,
|
||||
requestId: string,
|
||||
partyRole: PartyRole,
|
||||
isAccept: boolean,
|
||||
signFile: Express.Multer.File,
|
||||
): Promise<UserSignatureResponseDto> {
|
||||
if (!signFile) {
|
||||
throw new BadRequestException("A signature file is required");
|
||||
}
|
||||
const req = await this.blameRequestDbService.findById(requestId);
|
||||
if (!req) throw new NotFoundException("Request not found");
|
||||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.IN_PERSON) {
|
||||
throw new BadRequestException(
|
||||
"This endpoint is only for expert-initiated IN_PERSON blame files.",
|
||||
);
|
||||
}
|
||||
this.verifyExpertAccessForBlameV2(req, expert);
|
||||
if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
|
||||
throw new BadRequestException(
|
||||
"Request is not waiting for signatures. Current status: " + req.status,
|
||||
);
|
||||
}
|
||||
const partyIndex = this.getPartyIndex(req, partyRole);
|
||||
if (partyIndex === -1) {
|
||||
throw new BadRequestException(`Party ${partyRole} not found on this request`);
|
||||
}
|
||||
if (req.type === BlameRequestType.CAR_BODY && partyRole === PartyRole.SECOND) {
|
||||
throw new BadRequestException("CAR_BODY has only first party; use partyRole FIRST.");
|
||||
}
|
||||
const party = req.parties[partyIndex];
|
||||
if (party.confirmation) {
|
||||
throw new BadRequestException(
|
||||
`Party ${partyRole} has already signed.`,
|
||||
);
|
||||
}
|
||||
const partyUserId = party.person?.userId ? String(party.person.userId) : undefined;
|
||||
const signDoc = await this.userSign.create({
|
||||
fileName: signFile.filename,
|
||||
userId: partyUserId || expert.sub,
|
||||
path: signFile.path,
|
||||
requestId: new Types.ObjectId(requestId),
|
||||
});
|
||||
const signatureData = {
|
||||
fileId: String((signDoc as any)._id),
|
||||
fileName: signFile.filename,
|
||||
fileUrl: signFile.path,
|
||||
};
|
||||
const updatePayload: any = {
|
||||
$set: {
|
||||
[`parties.${partyIndex}.confirmation`]: {
|
||||
partyRole: party.role,
|
||||
accepted: isAccept,
|
||||
signature: signatureData,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload);
|
||||
const updatedRequest = await this.blameRequestDbService.findById(requestId);
|
||||
const requiredCount = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
|
||||
const signedParties = (updatedRequest.parties || []).filter(
|
||||
(p) => p.confirmation != null && p.confirmation !== undefined,
|
||||
);
|
||||
let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES;
|
||||
let message = "Signature recorded successfully.";
|
||||
if (signedParties.length >= requiredCount) {
|
||||
const allAccepted = updatedRequest.parties
|
||||
.slice(0, requiredCount)
|
||||
.every((p) => p.confirmation?.accepted === true);
|
||||
if (allAccepted) {
|
||||
finalStatus = CaseStatus.COMPLETED;
|
||||
message = "All parties have signed. Blame case completed.";
|
||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||||
$set: {
|
||||
status: CaseStatus.COMPLETED,
|
||||
"workflow.currentStep": WorkflowStep.COMPLETED as any,
|
||||
"workflow.nextStep": null,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
finalStatus = CaseStatus.CANCELLED;
|
||||
message = "One or both parties rejected. Case requires in-person resolution.";
|
||||
await this.blameRequestDbService.findByIdAndUpdate(requestId, {
|
||||
$set: {
|
||||
status: CaseStatus.CANCELLED,
|
||||
"workflow.currentStep": WorkflowStep.COMPLETED as any,
|
||||
"workflow.nextStep": null,
|
||||
},
|
||||
$push: {
|
||||
"workflow.completedSteps": WorkflowStep.WAITING_FOR_SIGNATURES as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
requestId: String(req._id),
|
||||
accepted: isAccept,
|
||||
status: finalStatus,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Expert adds accident fields to expert-initiated BlameRequest.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user