1
0
forked from Yara724/api

Added Field Expert flows, and deactivated inquiry

This commit is contained in:
2026-03-16 15:39:39 +03:30
parent b40270f058
commit fc599acf4a
10 changed files with 1067 additions and 88 deletions

View File

@@ -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) * V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
* *
@@ -2899,6 +3034,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
body: SelectOuterPartsV2Dto, body: SelectOuterPartsV2Dto,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOuterPartsV2ResponseDto> { ): Promise<SelectOuterPartsV2ResponseDto> {
try { try {
// 1. Validate claim exists // 1. Validate claim exists
@@ -2910,8 +3046,13 @@ export class ClaimRequestManagementService {
); );
} }
// 2. Validate user is the claim owner (damaged party) const effectiveUserId = await this.resolveClaimEffectiveUserId(
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) { 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( throw new ForbiddenException(
'Only the claim owner (damaged party) can select damaged parts' 'Only the claim owner (damaged party) can select damaged parts'
); );
@@ -3013,6 +3154,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
body: SelectOtherPartsV2Dto, body: SelectOtherPartsV2Dto,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
try { try {
// 1. Validate claim exists // 1. Validate claim exists
@@ -3024,8 +3166,13 @@ export class ClaimRequestManagementService {
); );
} }
// 2. Validate user is the claim owner (damaged party) const effectiveUserId = await this.resolveClaimEffectiveUserId(
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) { 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( throw new ForbiddenException(
'Only the claim owner (damaged party) can submit other parts and bank information' 'Only the claim owner (damaged party) can submit other parts and bank information'
); );
@@ -3126,6 +3273,7 @@ export class ClaimRequestManagementService {
async getCaptureRequirementsV2( async getCaptureRequirementsV2(
claimRequestId: string, claimRequestId: string,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<GetCaptureRequirementsV2ResponseDto> { ): Promise<GetCaptureRequirementsV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3134,7 +3282,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 }; 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 = [ const requiredDocsDefinition = [
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' }, { 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' }, { 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_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_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' }, { key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیب‌دیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' }, ...(isCarBody ? [] : [
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' }, { key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' }, { key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' }, { key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', 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 => { const requiredDocuments = requiredDocsDefinition.map(doc => {
@@ -3256,6 +3415,7 @@ export class ClaimRequestManagementService {
body: UploadRequiredDocumentV2Dto, body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3264,7 +3424,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 // Check if document already uploaded
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey); const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
if (existingDoc?.uploaded) { if (existingDoc?.uploaded) {
@@ -3318,11 +3493,11 @@ export class ClaimRequestManagementService {
}, },
}; };
// Check if all documents are uploaded // Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY)
const totalDocs = 13; const totalDocsRequired = isCarBodyUpload ? 7 : 13;
const currentDocs = claimCase.requiredDocuments || new Map(); const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; 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 all documents uploaded, move to next step
if (allDocumentsUploaded) { if (allDocumentsUploaded) {
@@ -3350,7 +3525,7 @@ export class ClaimRequestManagementService {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
const remaining = totalDocs - uploadedCount; const remaining = totalDocsRequired - uploadedCount;
const message = allDocumentsUploaded const message = allDocumentsUploaded
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.' ? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
: `Document uploaded successfully. ${remaining} documents remaining.`; : `Document uploaded successfully. ${remaining} documents remaining.`;
@@ -3378,6 +3553,7 @@ export class ClaimRequestManagementService {
body: CapturePartV2Dto, body: CapturePartV2Dto,
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<CapturePartV2ResponseDto> { ): Promise<CapturePartV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3386,7 +3562,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 { try {
const claims = await this.claimCaseDbService.find( let claims: any[];
{ 'owner.userId': new Types.ObjectId(currentUserId) }, if (actor?.role === RoleEnum.FIELD_EXPERT) {
{ lean: true }, 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) => ({ const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(), claimRequestId: c._id.toString(),
publicId: c.publicId, publicId: c.publicId,
@@ -3530,13 +3739,19 @@ export class ClaimRequestManagementService {
async getClaimDetailsV2( async getClaimDetailsV2(
claimRequestId: string, claimRequestId: string,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<ClaimDetailsV2ResponseDto> { ): Promise<ClaimDetailsV2ResponseDto> {
try { try {
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) { if (!claim) {
throw new NotFoundException('Claim request not found'); 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'); throw new ForbiddenException('You do not have access to this claim');
} }

View File

@@ -33,7 +33,7 @@ import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
@Controller("v2/claim-request-management") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER) @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -51,7 +51,7 @@ export class ClaimRequestManagementV2Controller {
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub); return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -91,6 +91,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2( return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -231,6 +232,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -345,6 +347,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw 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( return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -434,16 +438,16 @@ Returns status of each item (uploaded/captured or not).
description: ` description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim) **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 - car_green_card
- damaged_driving_license_front/back - damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo - damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate - damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back - guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
- guilty_car_card_front/back, guilty_metal_plate
**When all 13 documents are uploaded:** **When all required documents are uploaded:** Workflow moves to CAPTURE_PART_DAMAGES (Step 5).
- Workflow automatically 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({ @ApiParam({
@@ -507,6 +511,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw 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 2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.** **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({ @ApiParam({
@@ -598,6 +605,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;

View File

@@ -198,7 +198,18 @@ export class ExpertBlameService {
// Filter to show only: // Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision) // 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert // 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 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 status = doc.status as string;
const decision = doc.expert as any; const decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId; const decidedByExpertId = decision?.decision?.decidedByExpertId;
@@ -522,13 +533,20 @@ export class ExpertBlameService {
); );
} }
// Access control: Check if expert has permission to view this request // Access control: Expert-initiated files only visible to the initiating field expert
const decision = (doc.expert as any)?.decision; const expertInitiated = doc.expertInitiated === true;
const decidedByExpertId = decision?.decidedByExpertId; 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: // Allow if:
// 1. No decision yet (fresh request) // 1. No decision yet (fresh request)
// 2. Decision made by current expert // 2. Decision made by current expert
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
if (decidedByExpertId && String(decidedByExpertId) !== actorId) { if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.", "You do not have permission to view this request. 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 // Check if locked and not expired
if (request.workflow?.locked) { if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt; const lockedAt = request.workflow.lockedAt;

View File

@@ -22,7 +22,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame") @Controller("v2/expert-blame")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard) @UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT) @Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller { export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) {} constructor(private readonly expertBlameService: ExpertBlameService) {}

View File

@@ -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;
}

View 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;
}

View 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;
}

View File

@@ -31,7 +31,11 @@ import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-par
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto"; import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.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 { 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. * V2 expert-initiated blame API: uses BlameRequest (workflow) model.
@@ -80,7 +84,51 @@ export class ExpertInitiatedV2Controller {
description: description:
"Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.", "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({ @ApiResponse({
status: 201, status: 201,
description: "File created", 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") @Post("complete-blame-data/:requestId")
@ApiOperation({ @ApiOperation({
summary: "[V2] Submit all blame data (IN_PERSON)", summary: "[V2] Submit all blame data (IN_PERSON)",
description: 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" }) @ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({ @ApiBody({
description: 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.",
"ExpertCompleteThirdPartyFormDto for THIRD_PARTY or ExpertCompleteCarBodyFormDto for CAR_BODY", 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( async completeBlameDataV2(
@CurrentUser() expert: any, @CurrentUser() expert: any,
@Param("requestId") requestId: string, @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") @Post("complete-claim-data/:claimRequestId")
@ApiOperation({ @ApiOperation({
summary: "Submit claim-needed data (expert-initiated IN_PERSON)", summary: "Submit claim-needed data (expert-initiated IN_PERSON)",

View File

@@ -12,7 +12,9 @@ import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module"; import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module"; import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { PublicIdModule } from "src/utils/public-id/public-id.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 { 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 { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
import { UserSignDbService } from "./entities/db-service/sign.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service";
@@ -44,6 +46,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
SandHubModule, SandHubModule,
SmsManagerModule, SmsManagerModule,
PublicIdModule, PublicIdModule,
HashModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
PlatesModule, PlatesModule,
MulterModule.register({ MulterModule.register({
@@ -59,6 +62,7 @@ import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
]), ]),
forwardRef(() => ClaimRequestManagementModule), forwardRef(() => ClaimRequestManagementModule),
CronModule, CronModule,
AuthModule,
], ],
controllers: [ controllers: [
RequestManagementController, RequestManagementController,

View File

@@ -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 { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { UploadContext } from "./entities/schema/blame-voice.schema"; 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() @Injectable()
export class RequestManagementService { 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 { private getPartyIndex(req: any, role: PartyRole): number {
return Array.isArray(req.parties) return Array.isArray(req.parties)
? req.parties.findIndex((p) => p?.role === role) ? req.parties.findIndex((p) => p?.role === role)
@@ -292,6 +302,8 @@ export class RequestManagementService {
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly publicIdService: PublicIdService, private readonly publicIdService: PublicIdService,
private readonly workflowStepDbService: WorkflowStepDbService, private readonly workflowStepDbService: WorkflowStepDbService,
private readonly hashService: HashService,
private readonly userAuthService: UserAuthService,
) {} ) {}
async createRequestV2(user: any, type: BlameRequestType) { async createRequestV2(user: any, type: BlameRequestType) {
@@ -738,12 +750,12 @@ export class RequestManagementService {
let inquiryRaw: any; let inquiryRaw: any;
let inquiryMapped: any; let inquiryMapped: any;
try { try {
const inquiry = await this.sandHubService.getTejaratBlockInquiry({ // const inquiry = await this.sandHubService.getTejaratBlockInquiry({
plate: body.plate, // plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer, // nationalCodeOfInsurer: body.nationalCodeOfInsurer,
}); // });
inquiryRaw = inquiry.raw; // inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped; // inquiryMapped = inquiry.mapped;
this.logger.log( this.logger.log(
`[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, `[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; if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.isNew = body.isNewCar; 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.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = { 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); const secondPartyIdx = this.getPartyIndex(req, PartyRole.SECOND);
if (secondPartyIdx !== -1) { 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"); 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 --- // --- Proceed with existing logic if the check passes ---
// 1) Main third-party/block inquiry (existing behavior) // 1) Main third-party/block inquiry (existing behavior)
const sanHubResponse = await this.sandHubService.getSandHubResponse({ // const sanHubResponse = await this.sandHubService.getSandHubResponse({
plate: body.plate, // plate: body.plate,
nationalCodeOfInsurer: body.nationalCodeOfInsurer, // nationalCodeOfInsurer: body.nationalCodeOfInsurer,
}); // });
if (sanHubResponse.Error) { // if (sanHubResponse.Error) {
throw new HttpException( // throw new HttpException(
sanHubResponse.Error.Message, // sanHubResponse.Error.Message,
HttpStatus.BAD_REQUEST, // HttpStatus.BAD_REQUEST,
); // );
} // }
// 2) Personal inquiry (new shared SandHub API) // 2) Personal inquiry (new shared SandHub API)
// This is a best-effort call; if it fails we log and continue. // This is a best-effort call; if it fails we log and continue.
try { // try {
const personalInquiry = await this.sandHubService.getPersonalInquiry( // const personalInquiry = await this.sandHubService.getPersonalInquiry(
body.nationalCodeOfInsurer, // body.nationalCodeOfInsurer,
body.insurerBirthday, // body.insurerBirthday,
); // );
this.logger.log( // this.logger.log(
`[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify( // `[SANDHUB] Personal inquiry succeeded for nationalCode=${body.nationalCodeOfInsurer}: ${JSON.stringify(
personalInquiry, // personalInquiry,
)}`, // )}`,
); // );
// NOTE: We are not persisting this data yet; once the exact fields // // NOTE: We are not persisting this data yet; once the exact fields
// are finalized, we can map and store them on the request document. // // are finalized, we can map and store them on the request document.
} catch (err) { // } catch (err) {
this.logger.error( // this.logger.error(
`[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`, // `[SANDHUB] Personal inquiry failed for nationalCode=${body.nationalCodeOfInsurer}: ${err?.message || err}`,
); // );
} // }
return await this.addPlate( // return await this.addPlate(
sanHubResponse, // sanHubResponse,
request, // request,
user, // user,
body, // body,
isFirstParty ? "firstParty" : "secondParty", // isFirstParty ? "firstParty" : "secondParty",
); // );
} }
async carBodyFormStep( async carBodyFormStep(
@@ -3463,6 +3523,212 @@ export class RequestManagementService {
return result; 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). * List all expert-initiated blame files for the current field expert (their panel).
* Only files where initiatedBy === current user are returned. * Only files where initiatedBy === current user are returned.
@@ -3684,7 +3950,7 @@ export class RequestManagementService {
driverBirthday: firstPartyPlate.driverBirthday, driverBirthday: firstPartyPlate.driverBirthday,
}, },
vehicle: { vehicle: {
plateId: firstPartyPlate.plate, plateId: this.plateToPlateIdString(firstPartyPlate.plate) || (firstPartyPlate as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName, name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam, model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
@@ -3723,7 +3989,7 @@ export class RequestManagementService {
req.parties = [firstParty]; req.parties = [firstParty];
req.workflow = { req.workflow = {
currentStep: WorkflowStep.COMPLETED, currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
nextStep: undefined, nextStep: undefined,
completedSteps: [ completedSteps: [
WorkflowStep.CREATED, WorkflowStep.CREATED,
@@ -3736,7 +4002,7 @@ export class RequestManagementService {
].filter((s) => s), ].filter((s) => s),
locked: false, locked: false,
}; };
req.status = CaseStatus.COMPLETED; req.status = CaseStatus.WAITING_FOR_SIGNATURES;
req.blameStatus = BlameStatus.AGREED; req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT; (req as any).filledBy = FilledBy.EXPERT;
@@ -3827,7 +4093,7 @@ export class RequestManagementService {
driverBirthday: plateDto.driverBirthday, driverBirthday: plateDto.driverBirthday,
}, },
vehicle: { vehicle: {
plateId: plateDto.plate, plateId: this.plateToPlateIdString(plateDto.plate) || (plateDto as any).plateId,
name: sandHubReport?.MapTypNam || sandHubReport?.CarName, name: sandHubReport?.MapTypNam || sandHubReport?.CarName,
model: sandHubReport?.MapTypNam, model: sandHubReport?.MapTypNam,
type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`, type: `${sandHubReport?.UsageField || ""} / ${sandHubReport?.MapUsageName || "-"}`,
@@ -3867,8 +4133,14 @@ export class RequestManagementService {
); );
req.parties = [firstParty, secondParty]; 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 = { req.workflow = {
currentStep: WorkflowStep.COMPLETED, currentStep: WorkflowStep.WAITING_FOR_SIGNATURES as any,
nextStep: undefined, nextStep: undefined,
completedSteps: [ completedSteps: [
WorkflowStep.CREATED, WorkflowStep.CREATED,
@@ -3885,7 +4157,7 @@ export class RequestManagementService {
].filter((s) => s), ].filter((s) => s),
locked: false, locked: false,
}; };
req.status = CaseStatus.COMPLETED; req.status = CaseStatus.WAITING_FOR_SIGNATURES;
req.blameStatus = BlameStatus.AGREED; req.blameStatus = BlameStatus.AGREED;
(req as any).filledBy = FilledBy.EXPERT; (req as any).filledBy = FilledBy.EXPERT;
@@ -4006,6 +4278,116 @@ export class RequestManagementService {
return { requestId: req._id }; 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. * V2: Expert adds accident fields to expert-initiated BlameRequest.
*/ */