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)
*
@@ -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' },
{ 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' },
...(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(
{ 'owner.userId': new Types.ObjectId(currentUserId) },
{ lean: true },
);
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');
}

View File

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

View File

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

View File

@@ -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) {}

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 { 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)",

View File

@@ -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,

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 { 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.
*/