diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index bc95685..65cb8e9 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -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 { + 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 { + 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 { 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 { 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 { 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 { 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 { 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 { + async getMyClaimsV2( + currentUserId: string, + actor?: { sub: string; role?: string }, + ): Promise { 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 { 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'); } diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts index 349b801..765751d 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -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 { 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; diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index dd14c80..4442d07 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -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[]).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; diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index b89afca..ce42b7f 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -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) {} diff --git a/src/request-management/dto/expert-upload-party-signature.dto.ts b/src/request-management/dto/expert-upload-party-signature.dto.ts new file mode 100644 index 0000000..cfe7098 --- /dev/null +++ b/src/request-management/dto/expert-upload-party-signature.dto.ts @@ -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; +} diff --git a/src/request-management/dto/send-party-otps.dto.ts b/src/request-management/dto/send-party-otps.dto.ts new file mode 100644 index 0000000..cc7b757 --- /dev/null +++ b/src/request-management/dto/send-party-otps.dto.ts @@ -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; +} diff --git a/src/request-management/dto/verify-party-otps.dto.ts b/src/request-management/dto/verify-party-otps.dto.ts new file mode 100644 index 0000000..fe5eaf5 --- /dev/null +++ b/src/request-management/dto/verify-party-otps.dto.ts @@ -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; +} diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index c213229..4b7a392 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -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)", diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index a5e8bb6..ae14395 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -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, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 8fa6b4a..ce82201 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -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 => { + 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 { + 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. */