diff --git a/src/auth/guards/global.guard.ts b/src/auth/guards/global.guard.ts index dd5ebad..bbe2e97 100644 --- a/src/auth/guards/global.guard.ts +++ b/src/auth/guards/global.guard.ts @@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); if (!token) { throw new UnauthorizedException(); } + try { const payload = await this.jwtService.verifyAsync(token, { secret: `${process.env.SECRET}`, }); - if (payload.role !== RoleEnum.USER) { - console.log( - "🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:", - request.user.role, - ); + + if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) { throw new UnauthorizedException(); } + request.user = payload; request.identity = request.user; } catch { diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 702ace6..b330aea 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -68,7 +68,9 @@ import { PublicIdService } from "src/utils/public-id/public-id.service"; import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; -import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum"; +import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; +import { CreationMethod } from "src/request-management/entities/schema/request-management.schema"; +import { RoleEnum } from "src/Types&Enums/role.enum"; import { UserRatingDto } from "./dto/user-rating.dto"; @Injectable() @@ -341,6 +343,166 @@ export class ClaimRequestManagementService { return trueItems; } + /** One section of CarDamagePartDto: either a single object or an array of objects (legacy). */ + private carPartDamageSectionToRows(section: unknown): Record[] { + if (section == null) return []; + if (Array.isArray(section)) { + return section.filter( + (row): row is Record => + !!row && typeof row === "object", + ); + } + if (typeof section === "object") { + return [section as Record]; + } + return []; + } + + private carPartDamageSectionHasTrue(section: unknown): boolean { + for (const row of this.carPartDamageSectionToRows(section)) { + for (const val of Object.values(row)) { + if (val === true) return true; + } + } + return false; + } + + /** + * At most two of left, right, front, back may contain any damaged part (true). + * `top` and claim `otherParts` are not subject to this rule. + */ + private assertCarPartDamageAtMostTwoOfFourSides(dto: CarDamagePartDto): void { + const d = dto as unknown as Record; + const quadrants = [ + { key: "left", section: d.left }, + { key: "right", section: d.right }, + { key: "front", section: d.front }, + { key: "back", section: d.back }, + ]; + const used = quadrants.filter((q) => + this.carPartDamageSectionHasTrue(q.section), + ); + if (used.length > 2) { + throw new BadRequestException( + `At most two of the four sides (left, right, front, back) may include damaged parts. ` + + `Sides with selections: ${used.map((u) => u.key).join(", ")}.`, + ); + } + } + + /** + * Maps legacy expert CarDamagePartDto (nested booleans) to v2 outer part slugs (ClaimCase.damage.selectedParts). + */ + private carDamagePartDtoToOuterPartSlugs(dto: CarDamagePartDto): string[] { + const out = new Set(); + const add = (slug: string | undefined) => { + if (slug) out.add(slug); + }; + const walkMainOnSide = ( + side: "left" | "right", + items: unknown, + map: Record, + ) => { + for (const row of this.carPartDamageSectionToRows(items)) { + for (const [key, val] of Object.entries(row)) { + if (val !== true) continue; + const slug = map[`${side}_${key}`] ?? map[key]; + add(slug); + } + } + }; + // MainParts on left / right (side-specific door / fender) + walkMainOnSide("left", (dto as any).left, { + left_frontDoor: "front_left_door", + left_backDoor: "rear_left_door", + left_frontFender: "front_left_fender", + left_backFender: "rear_left_fender", + frontDoor: "front_left_door", + backDoor: "rear_left_door", + frontFender: "front_left_fender", + backFender: "rear_left_fender", + }); + walkMainOnSide("right", (dto as any).right, { + right_frontDoor: "front_right_door", + right_backDoor: "rear_right_door", + right_frontFender: "front_right_fender", + right_backFender: "rear_right_fender", + frontDoor: "front_right_door", + backDoor: "rear_right_door", + frontFender: "front_right_fender", + backFender: "rear_right_fender", + }); + const walkSimple = (items: unknown, keyToSlug: Record) => { + for (const row of this.carPartDamageSectionToRows(items)) { + for (const [key, val] of Object.entries(row)) { + if (val === true) add(keyToSlug[key]); + } + } + }; + walkSimple((dto as any).front, { + frontBumper: "front_bumper", + carHood: "hood", + }); + walkSimple((dto as any).back, { + backBumper: "rear_bumper", + carTrunk: "trunk", + }); + walkSimple((dto as any).top, { + roof: "roof", + }); + return Array.from(out); + } + + /** Normalize expert DTO otherParts (strings or legacy { label: true }[]) to string[]. */ + private normalizeExpertOtherPartsInput(raw: any[] | undefined): string[] { + if (!raw || !Array.isArray(raw)) return []; + const out: string[] = []; + for (const item of raw) { + if (typeof item === "string" && item.trim()) { + out.push(item.trim()); + continue; + } + if (item && typeof item === "object") { + for (const [k, v] of Object.entries(item)) { + if (v === true && k) out.push(k); + } + } + } + return out; + } + + private async assertFieldExpertInPersonBlameForClaimCase( + claimCase: any, + expert: any, + ): Promise { + const blameRequestId = claimCase?.blameRequestId?.toString?.(); + if (!blameRequestId) { + throw new BadRequestException("Claim case has no linked blame request."); + } + const blameRequest = await this.blameRequestDbService.findById(blameRequestId); + if (!blameRequest) { + throw new NotFoundException("Blame request not found for this claim."); + } + if ( + !blameRequest.expertInitiated || + blameRequest.creationMethod !== CreationMethod.IN_PERSON + ) { + throw new BadRequestException( + "This endpoint is only for expert-initiated IN_PERSON claim files.", + ); + } + if (!blameRequest.initiatedByFieldExpertId) { + throw new BadRequestException( + "Blame request is missing initiating field expert.", + ); + } + if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) { + throw new ForbiddenException( + "Only the initiating field expert can complete claim data for this file.", + ); + } + } + async setCarPartDamageAndImage(cl, trueItems) { cl.carPartDamage = trueItems; const jsonTrueItems = JSON.parse(JSON.stringify(trueItems)); @@ -394,7 +556,9 @@ export class ClaimRequestManagementService { /** * Field expert completes claim-needed data for expert-initiated IN_PERSON files. - * Only the initiating expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts. + * Only the initiating field expert can call this. Accepts car part damage and/or sheba, nationalCodeOfInsurer, otherParts. + * + * V2: Resolves `ClaimCase` in `claimCases` collection (workflow). Legacy v1 claim documents are still supported. */ async expertCompleteClaimData( claimRequestId: string, @@ -406,6 +570,16 @@ export class ClaimRequestManagementService { otherParts?: any[]; }, ): Promise<{ success: boolean; claimRequestId: string }> { + const claimCase = await this.claimCaseDbService.findById(claimRequestId); + if (claimCase) { + return this.expertCompleteClaimDataForClaimCaseV2( + claimRequestId, + expert, + dto, + claimCase, + ); + } + const claim = await this.claimDbService.findOne(claimRequestId); if (!claim) { throw new NotFoundException("Claim file not found"); @@ -427,6 +601,7 @@ export class ClaimRequestManagementService { if (claim.carPartDamage && (claim.carPartDamage as any[]).length > 0) { throw new BadRequestException("Car part damage is already set."); } + this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); const trueItems = this.findTrueItems(dto.carPartDamage); const claimDoc = (await this.claimDbService.findOne( claimRequestId, @@ -455,6 +630,178 @@ export class ClaimRequestManagementService { return { success: true, claimRequestId }; } + /** + * V2 ClaimCase: outer parts + optional bank/other parts (expert-initiated IN_PERSON only). + */ + private async expertCompleteClaimDataForClaimCaseV2( + claimRequestId: string, + expert: any, + dto: { + carPartDamage?: CarDamagePartDto; + sheba?: string; + nationalCodeOfInsurer?: string; + otherParts?: any[]; + }, + claimCase: any, + ): Promise<{ success: boolean; claimRequestId: string }> { + await this.assertFieldExpertInPersonBlameForClaimCase(claimCase, expert); + + let working = claimCase; + + if (dto.carPartDamage) { + if (working.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) { + throw new BadRequestException( + `Cannot set outer parts: workflow step must be ${ClaimWorkflowStep.SELECT_OUTER_PARTS}. Current: ${working.workflow?.currentStep}`, + ); + } + if ( + working.damage?.selectedParts && + working.damage.selectedParts.length > 0 + ) { + throw new BadRequestException("Car part damage is already set."); + } + this.assertCarPartDamageAtMostTwoOfFourSides(dto.carPartDamage); + const selectedParts = this.carDamagePartDtoToOuterPartSlugs(dto.carPartDamage); + if (!selectedParts.length) { + throw new BadRequestException( + "No outer damaged parts could be derived from carPartDamage. Check part selections.", + ); + } + + const updated = await this.claimCaseDbService.findByIdAndUpdate( + claimRequestId, + { + "damage.selectedParts": selectedParts, + status: ClaimCaseStatus.SELECTING_OTHER_PARTS, + "workflow.currentStep": ClaimWorkflowStep.SELECT_OTHER_PARTS, + "workflow.nextStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OUTER_PARTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || + "field_expert", + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, + selectedParts, + description: "Field expert submitted outer damage parts", + }, + }, + }, + }, + ); + if (!updated) { + throw new InternalServerErrorException("Failed to update claim case"); + } + working = updated; + } + + const hasBankOrOther = + dto.sheba != null || + dto.nationalCodeOfInsurer != null || + dto.otherParts != null; + if (hasBankOrOther) { + const fresh = await this.claimCaseDbService.findById(claimRequestId); + if (!fresh) throw new NotFoundException("Claim case not found"); + if (fresh.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) { + throw new BadRequestException( + `Bank/other parts can only be set at ${ClaimWorkflowStep.SELECT_OTHER_PARTS}. Current: ${fresh.workflow?.currentStep}`, + ); + } + + const mergedSheba = + dto.sheba != null ? dto.sheba : fresh.money?.sheba; + const mergedNational = + dto.nationalCodeOfInsurer != null + ? dto.nationalCodeOfInsurer + : fresh.money?.nationalCodeOfInsurer; + + if (fresh.money?.sheba && dto.sheba != null && dto.sheba !== fresh.money.sheba) { + throw new ConflictException("Sheba is already set for this claim."); + } + if ( + fresh.money?.nationalCodeOfInsurer && + dto.nationalCodeOfInsurer != null && + dto.nationalCodeOfInsurer !== fresh.money.nationalCodeOfInsurer + ) { + throw new ConflictException( + "National code of insurer is already set for this claim.", + ); + } + + const otherPartsArr = this.normalizeExpertOtherPartsInput(dto.otherParts); + const setPatch: Record = {}; + if (dto.sheba != null) setPatch["money.sheba"] = dto.sheba; + if (dto.nationalCodeOfInsurer != null) { + setPatch["money.nationalCodeOfInsurer"] = dto.nationalCodeOfInsurer; + } + if (dto.otherParts != null) { + setPatch["damage.otherParts"] = + otherPartsArr.length > 0 ? otherPartsArr : []; + } + + const bankComplete = + mergedSheba && + mergedNational && + /^[0-9]{24}$/.test(String(mergedSheba).replace(/\s/g, "")) && + /^[0-9]{10}$/.test(String(mergedNational).replace(/\s/g, "")); + + if (bankComplete) { + const shebaNorm = String(mergedSheba).replace(/\s/g, ""); + const nationalNorm = String(mergedNational).replace(/\s/g, ""); + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + "money.sheba": shebaNorm, + "money.nationalCodeOfInsurer": nationalNorm, + ...(dto.otherParts != null + ? { + "damage.otherParts": + otherPartsArr.length > 0 ? otherPartsArr : [], + } + : {}), + status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + "workflow.currentStep": ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + "workflow.nextStep": ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + $push: { + "workflow.completedSteps": ClaimWorkflowStep.SELECT_OTHER_PARTS, + history: { + type: "STEP_COMPLETED", + actor: { + actorId: new Types.ObjectId(expert.sub), + actorName: + `${expert.firstName || ""} ${expert.lastName || ""}`.trim() || + "field_expert", + actorType: "field_expert", + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, + description: + "Field expert submitted bank info and optional other parts", + }, + }, + }, + }); + } else { + if (Object.keys(setPatch).length === 0) { + throw new BadRequestException( + "Provide sheba (24 digits) and nationalCodeOfInsurer (10 digits) together to finish this step, or supply partial fields incrementally.", + ); + } + await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { + $set: setPatch, + }); + } + } + + return { success: true, claimRequestId }; + } + async selectCarOtherPartDamage( requestId: string, body: any, // Use your DTO for better type safety @@ -3349,7 +3696,23 @@ export class ClaimRequestManagementService { } // 5. Convert selected parts to storage format (simple array of strings) - const selectedParts = body.selectedParts; + // Backward compatibility: some clients may still send legacy `carPartDamage` object. + let selectedParts = Array.isArray((body as any)?.selectedParts) + ? ((body as any).selectedParts as string[]) + : []; + if (selectedParts.length === 0 && (body as any)?.carPartDamage) { + this.assertCarPartDamageAtMostTwoOfFourSides( + (body as any).carPartDamage as CarDamagePartDto, + ); + selectedParts = this.carDamagePartDtoToOuterPartSlugs( + (body as any).carPartDamage as CarDamagePartDto, + ); + } + if (selectedParts.length === 0) { + throw new BadRequestException( + "selectedParts is required and must contain at least one item", + ); + } // 6. Update claim case with selected parts and move to next step const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( @@ -3372,8 +3735,8 @@ export class ClaimRequestManagementService { metadata: { stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS, selectedParts: selectedParts, - partsCount: selectedParts.length, - description: `User selected ${selectedParts.length} damaged outer parts`, + partsCount: selectedParts?.length, + description: `User selected ${selectedParts?.length} damaged outer parts`, }, }, }, @@ -3385,7 +3748,7 @@ export class ClaimRequestManagementService { } this.logger.log( - `Outer parts selected for claim ${claimRequestId}: ${selectedParts.length} parts`, + `Outer parts selected for claim ${claimRequestId}: ${selectedParts?.length} parts`, ); // 7. Return response @@ -3431,6 +3794,7 @@ export class ClaimRequestManagementService { body: SelectOtherPartsV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, + file?: Express.Multer.File, ): Promise { try { // 1. Validate claim exists @@ -3469,40 +3833,108 @@ export class ClaimRequestManagementService { } // 5. Prepare data - const otherParts = body.otherParts || []; - const shebaNumber = body.shebaNumber; - const nationalCode = body.nationalCodeOfOwner; + // Backward compatibility aliases: + // - shebaNumber <- sheba + // - nationalCodeOfOwner <- nationalCodeOfInsurer + let otherParts = Array.isArray((body as any)?.otherParts) + ? (body as any).otherParts + : []; + // Multipart clients may send otherParts as JSON string + if (!Array.isArray((body as any)?.otherParts) && typeof (body as any)?.otherParts === "string") { + try { + const parsed = JSON.parse((body as any).otherParts); + otherParts = Array.isArray(parsed) ? parsed : []; + } catch { + otherParts = []; + } + } + const shebaInput = String( + ((body as any).shebaNumber ?? (body as any).sheba ?? "") as string, + ) + .replace(/\s/g, ""); - // 6. Update claim case with other parts, bank info and move to next step - const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( - claimRequestId, - { - 'damage.otherParts': otherParts.length > 0 ? otherParts : undefined, - 'money.sheba': shebaNumber, - 'money.nationalCodeOfInsurer': nationalCode, - 'status': ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, - 'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, - 'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES, - $push: { - 'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS, - history: { - type: 'STEP_COMPLETED', - actor: { - actorId: new Types.ObjectId(currentUserId), - actorName: claimCase.owner?.fullName || 'User', - actorType: 'user', - }, - timestamp: new Date(), - metadata: { - stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, - otherParts: otherParts, - otherPartsCount: otherParts.length, - hasBankInfo: true, - description: `User selected ${otherParts.length} other damaged parts and provided bank information`, - }, + const shebaDigits = shebaInput.replace(/^IR/i, ""); + const shebaNumber = `IR${shebaDigits}`; + const nationalCode = String( + ((body as any).nationalCodeOfOwner ?? + (body as any).nationalCodeOfInsurer ?? + "") as string, + ).replace(/\s/g, ""); + if (!/^[0-9]{24}$/.test(shebaDigits)) { + throw new BadRequestException( + "shebaNumber is required and must be valid (IR + 24 digits or only 24 digits)", + ); + } + if (!/^[0-9]{10}$/.test(nationalCode)) { + throw new BadRequestException( + "nationalCodeOfOwner is required and must be exactly 10 digits", + ); + } + + const updatePayload: any = { + 'damage.otherParts': otherParts.length > 0 ? otherParts : undefined, + 'money.sheba': shebaNumber, + 'money.nationalCodeOfInsurer': nationalCode, + 'status': ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, + 'workflow.currentStep': ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS, + 'workflow.nextStep': ClaimWorkflowStep.CAPTURE_PART_DAMAGES, + $push: { + 'workflow.completedSteps': ClaimWorkflowStep.SELECT_OTHER_PARTS, + history: { + type: 'STEP_COMPLETED', + actor: { + actorId: new Types.ObjectId(currentUserId), + actorName: claimCase.owner?.fullName || 'User', + actorType: 'user', + }, + timestamp: new Date(), + metadata: { + stepKey: ClaimWorkflowStep.SELECT_OTHER_PARTS, + otherParts: otherParts, + otherPartsCount: otherParts.length, + hasBankInfo: true, + hasGreenCardUpload: !!file, + description: `User selected ${otherParts.length} other damaged parts and provided bank information`, }, }, }, + }; + + if (file) { + const existingGreen = + (claimCase.requiredDocuments as any)?.get?.( + ClaimRequiredDocumentType.CAR_GREEN_CARD, + ) || + (claimCase.requiredDocuments as any)?.[ + ClaimRequiredDocumentType.CAR_GREEN_CARD + ]; + if (existingGreen?.uploaded) { + throw new ConflictException( + "car_green_card has already been uploaded for this claim", + ); + } + const docRef = await this.claimRequiredDocumentDbService.create({ + path: file.path, + fileName: file.filename, + claimId: new Types.ObjectId(claimRequestId), + documentType: ClaimRequiredDocumentType.CAR_GREEN_CARD, + uploadedAt: new Date(), + }); + updatePayload[ + `requiredDocuments.${ClaimRequiredDocumentType.CAR_GREEN_CARD}` + ] = { + fileId: docRef._id, + filePath: file.path, + fileName: file.filename, + uploaded: true, + uploadedAt: new Date(), + }; + } + + // 6. Update claim case with other parts, bank info and optional green card file + const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate( + claimRequestId, + updatePayload, ); if (!updatedClaim) { @@ -3514,8 +3946,8 @@ export class ClaimRequestManagementService { ); // 7. Mask sensitive data for response - const maskedSheba = shebaNumber.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3'); - const maskedNationalCode = nationalCode.replace(/^(.{2})(.*)(.{2})$/, '$1******$3'); + const maskedSheba = `IR${shebaDigits.slice(0, 4)}************${shebaDigits.slice(-4)}`; + const maskedNationalCode = `${nationalCode.slice(0, 2)}******${nationalCode.slice(-2)}`; // 8. Return response return { 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 e9293dd..ef5922c 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -344,13 +344,14 @@ export class ClaimRequestManagementV2Controller { **Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim) **Purpose:** User selects non-body damaged parts and provides bank information for payment. +Optional: upload car green card file in the same step. **Validations:** - Claim must exist - User must be the claim owner (damaged party from blame case) - Current workflow step must be SELECT_OTHER_PARTS - Bank information must not have been submitted previously -- Sheba number must be exactly 24 digits +- Sheba (sheba) accepted as IR + 24 digits or only 24 digits - National code must be exactly 10 digits **Valid Other Parts (Optional):** @@ -368,42 +369,38 @@ export class ClaimRequestManagementV2Controller { description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: 10 * 1024 * 1024 }, + storage: diskStorage({ + destination: "./files/claim-required-document", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `other-parts-${unique}${ex}`); + }, + }), + }), + ) @ApiBody({ - type: SelectOtherPartsV2Dto, - description: "Other parts selection and bank information", - examples: { - example1: { - summary: "Only bank info (no other parts damaged)", - value: { - otherParts: [], - shebaNumber: "123456789012345678901234", - nationalCodeOfOwner: "1234567890", - }, - }, - example2: { - summary: "Engine and suspension damage", - value: { - otherParts: ["engine", "suspension"], - shebaNumber: "123456789012345678901234", - nationalCodeOfOwner: "1234567890", - }, - }, - example3: { - summary: "Multiple systems damaged", - value: { - otherParts: ["engine", "brake_system", "electrical", "headlight"], - shebaNumber: "123456789012345678901234", - nationalCodeOfOwner: "1234567890", - }, - }, - example4: { - summary: "Lighting and glass damage", - value: { - otherParts: ["headlight", "taillight", "mirror", "glass"], - shebaNumber: "123456789012345678901234", - nationalCodeOfOwner: "1234567890", + description: + "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.", + schema: { + type: "object", + properties: { + otherParts: { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string", description: "JSON string array for multipart" }, + ], + example: ["engine", "suspension"], }, + sheba: { type: "string", example: "IR123456789012345678901234" }, + nationalCodeOfInsurer: { type: "string", example: "1234567890" }, + file: { type: "string", format: "binary" }, }, + required: ["sheba", "nationalCodeOfInsurer"], }, }) @ApiResponse({ @@ -431,6 +428,7 @@ export class ClaimRequestManagementV2Controller { @Param("claimRequestId") claimRequestId: string, @Body() body: SelectOtherPartsV2Dto, @CurrentUser() user: any, + @UploadedFile() file?: Express.Multer.File, ): Promise { try { return await this.claimRequestManagementService.selectOtherPartsV2( @@ -438,6 +436,7 @@ export class ClaimRequestManagementV2Controller { body, user.sub, user, + file, ); } catch (error) { if (error instanceof HttpException) throw error; diff --git a/src/claim-request-management/dto/select-other-parts-v2.dto.ts b/src/claim-request-management/dto/select-other-parts-v2.dto.ts index eadfe3a..8b3fe3e 100644 --- a/src/claim-request-management/dto/select-other-parts-v2.dto.ts +++ b/src/claim-request-management/dto/select-other-parts-v2.dto.ts @@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto { otherParts?: OtherCarPart[]; @ApiProperty({ - description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix', + description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits', + example: 'IR123456789012345678901234', + }) + @IsNotEmpty({ message: 'sheba is required' }) + @IsString({ message: 'sheba must be a string' }) + sheba: string; + + @ApiPropertyOptional({ + description: 'Legacy alias of sheba for backward compatibility', example: '123456789012345678901234', - pattern: '^[0-9]{24}$', - minLength: 24, - maxLength: 24, }) - @IsNotEmpty({ message: 'Sheba number is required' }) - @IsString({ message: 'Sheba number must be a string' }) - @Length(24, 24, { message: 'Sheba number must be exactly 24 digits' }) - @Matches(/^[0-9]{24}$/, { - message: 'Sheba number must contain exactly 24 digits (without IR prefix)', - }) - shebaNumber: string; + @IsOptional() + @IsString() + shebaNumber?: string; @ApiProperty({ - description: 'National code of the owner - 10 digits', + description: 'National code of insurer/owner - 10 digits', example: '1234567890', pattern: '^[0-9]{10}$', minLength: 10, maxLength: 10, }) - @IsNotEmpty({ message: 'National code of owner is required' }) + @IsNotEmpty({ message: 'nationalCodeOfInsurer is required' }) @IsString({ message: 'National code must be a string' }) @Length(10, 10, { message: 'National code must be exactly 10 digits' }) @Matches(/^[0-9]{10}$/, { message: 'National code must contain exactly 10 digits', }) - nationalCodeOfOwner: string; + nationalCodeOfInsurer: string; + + @ApiPropertyOptional({ + description: 'Legacy alias for backward compatibility', + example: '1234567890', + }) + @IsOptional() + @IsString() + nationalCodeOfOwner?: string; } /** diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index 4024294..e0a9902 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -7,6 +7,11 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { + assertBlameCaseForExpertTenant, + blameCaseAccessibleToExpert, + requireActorClientKey, +} from "src/helpers/tenant-scope"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { @@ -192,6 +197,7 @@ export class ExpertBlameService { */ async findAllV2(actor: any): Promise { try { + requireActorClientKey(actor); const expertId = actor.sub; // Fetch all DISAGREEMENT cases @@ -203,10 +209,15 @@ 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 + // 1. Same insurance tenant (party clientId or expert-initiated by this actor) + // 2. Fresh requests (WAITING_FOR_EXPERT and no decision) + // 3. Requests decided by current expert + // 4. Expert-initiated: only the initiating field expert sees them const visibleCases = (allCases as Record[]).filter((doc) => { + if (!blameCaseAccessibleToExpert(doc, actor)) { + return false; + } + const expertInitiated = doc.expertInitiated === true; const initiatedByFieldExpertId = doc.initiatedByFieldExpertId; @@ -625,100 +636,15 @@ export class ExpertBlameService { * Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence. * Access control: Only allows viewing fresh requests or requests decided by current expert. */ - // async findOneV2(requestId: string, actorId: string): Promise> { - // try { - // const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); - // if (!doc) { - // throw new NotFoundException("Request not found"); - // } - // const type = doc.type as string; - // if (type === BlameRequestType.CAR_BODY) { - // throw new ForbiddenException( - // "CAR_BODY type requests are automatically handled and do not require expert review.", - // ); - // } - - // // 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.", - // ); - // } - - // const parties = (doc.parties ?? []) as Array<{ - // evidence?: { videoId?: string; voices?: string[] }; - // [key: string]: unknown; - // }>; - // for (const party of parties) { - // if (!party.evidence) continue; - // const evidence = party.evidence as Record; - // if (evidence.videoId) { - // const videoDoc = await this.blameVideoDbService.findById( - // String(evidence.videoId), - // ); - // if (videoDoc?.path) { - // evidence.videoUrl = buildFileLink(videoDoc.path); - // } - // } - // if (evidence.voices && Array.isArray(evidence.voices)) { - // const voiceUrls: string[] = []; - // for (const voiceId of evidence.voices) { - // const voiceDoc = await this.blameVoiceDbService.findById( - // String(voiceId), - // ); - // if (voiceDoc?.path) { - // voiceUrls.push(buildFileLink(voiceDoc.path)); - // } - // } - // evidence.voiceUrls = voiceUrls; - // } - // } - - // const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date(); - // const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date(); - // const [createdDate, createdTime] = toJalaliDateAndTime(createdAt); - // const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt); - // doc.createdAtFormatted = `${createdDate} ${createdTime}`; - // doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`; - - // // Exclude mapped inquiry vehicle for both parties from response - // for (const party of parties) { - // delete party.vehicle; - // } - - // return doc; - // } catch (error) { - // if (error instanceof HttpException) throw error; - // this.logger.error( - // "findOneV2 failed", - // requestId, - // error instanceof Error ? error.stack : String(error), - // ); - // throw new InternalServerErrorException( - // error instanceof Error ? error.message : "Failed to get blame case details", - // ); - // } - // } - - async findOneV2(requestId: string, actorId: string): Promise> { + async findOneV2(requestId: string, actor: any): Promise> { try { - await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + requireActorClientKey(actor); + const actorId = actor.sub; const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId); - if (!doc) throw new NotFoundException("Request not found"); - + if (!doc) { + throw new NotFoundException("Request not found"); + } + assertBlameCaseForExpertTenant(doc, actor); const type = doc.type as string; if (type === BlameRequestType.CAR_BODY) { throw new ForbiddenException( @@ -821,12 +747,35 @@ export class ExpertBlameService { async lockRequest(requestId: string, actorDetail) { + const existing = await this.requestManagementDbService.findOne(requestId); + if (!existing) { + throw new NotFoundException("Request not found"); + } + if (existing.blameStatus === ReqBlameStatus.UserPending) { + throw new BadRequestException( + "Cannot lock request because blameStatus is UserPending", + ); + } + + const isLocked = !!existing.lockFile; + const lockedByCurrent = + String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub); + const isLockExpired = + !!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime(); + + // Idempotent behavior: same expert can re-enter their locked file. + if (isLocked && lockedByCurrent && !isLockExpired) { + return { _id: requestId, lock: true, message: "Already locked by you" }; + } + if (isLocked && !lockedByCurrent && !isLockExpired) { + throw new BadRequestException("Request is locked by another expert"); + } + const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const updateResult = await this.requestManagementDbService.findOneAndUpdate( { _id: requestId, - lockFile: false, blameStatus: { $ne: ReqBlameStatus.UserPending }, }, { @@ -880,6 +829,8 @@ export class ExpertBlameService { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actorDetail); + // Validate request is available for expert review if ( request.status !== CaseStatus.WAITING_FOR_EXPERT || @@ -909,7 +860,9 @@ export class ExpertBlameService { new Date(lockedAt).getTime() + this.blameV2LockTtlMs; if (Date.now() < lockExpiryTime) { // Lock is still valid - const lockedByActorId = String(request.workflow.lockedBy?.actorId); + const lockedByActorId = String( + request.workflow.lockedBy?.actorId ?? "", + ); if (lockedByActorId === actorDetail.sub) { throw new BadRequestException( "You have already locked this request", @@ -968,7 +921,7 @@ export class ExpertBlameService { async resendRequestV2( requestId: string, resendDto: ResendRequestDto, - actorId: string, + actor: any, ): Promise<{ requestId: string; status: string; @@ -981,12 +934,17 @@ export class ExpertBlameService { }> { try { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + + requireActorClientKey(actor); + const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actor); + // Validate request is locked by current expert if (!request.workflow?.locked) { throw new ForbiddenException( @@ -1135,16 +1093,20 @@ export class ExpertBlameService { async replyRequestV2( requestId: string, reply: SubmitReplyDto, - actorId: string, + actor: any, ): Promise<{ requestId: string; status: string }> { try { await this.expireBlameCaseWorkflowLockV2IfStale(requestId); + requireActorClientKey(actor); + const actorId = actor.sub; const request = await this.blameRequestDbService.findById(requestId); if (!request) { throw new NotFoundException("Request not found"); } + assertBlameCaseForExpertTenant(request, actor); + // Validate no decision exists yet if (request.expert?.decision) { throw new ForbiddenException( diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts index f7a6d56..708261a 100644 --- a/src/expert-blame/expert-blame.v2.controller.ts +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -42,7 +42,7 @@ export class ExpertBlameV2Controller { @ApiParam({ name: "id", description: "Blame case request id" }) async findOne(@Param("id") id: string, @CurrentUser() actor: any) { try { - return await this.expertBlameService.findOneV2(id, actor.sub); + return await this.expertBlameService.findOneV2(id, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( @@ -73,7 +73,7 @@ export class ExpertBlameV2Controller { @CurrentUser() actor: any, ) { try { - return await this.expertBlameService.resendRequestV2(id, body, actor.sub); + return await this.expertBlameService.resendRequestV2(id, body, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( @@ -93,7 +93,7 @@ export class ExpertBlameV2Controller { @CurrentUser() actor: any, ) { try { - return await this.expertBlameService.replyRequestV2(id, body, actor.sub); + return await this.expertBlameService.replyRequestV2(id, body, actor); } catch (error) { if (error instanceof HttpException) throw error; throw new InternalServerErrorException( diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index b6477f4..8734c40 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -33,6 +33,11 @@ import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum"; +import { + assertClaimCaseForTenant, + claimCaseTouchesClient, + requireActorClientKey, +} from "src/helpers/tenant-scope"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto"; import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto"; @@ -319,8 +324,18 @@ export class ExpertClaimService { await this.claimRequestManagementDbService.findOne(requestId); if (!request) throw new BadRequestException("Claim request not found"); - if (request.lockFile) { - throw new BadRequestException("Claim request is locked"); + const isLocked = !!request.lockFile; + const lockedByCurrent = + String(request?.actorLocked?.actorId || "") === String(actorDetail.sub); + const isLockExpired = + !!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime(); + + // Idempotent behavior: same expert can re-enter their locked file. + if (isLocked && lockedByCurrent && !isLockExpired) { + return { _id: requestId, lock: true, message: "Already locked by you" }; + } + if (isLocked && !lockedByCurrent && !isLockExpired) { + throw new BadRequestException("Claim request is locked by another expert"); } if (request.claimStatus === ReqClaimStatus.UserPending) { @@ -1602,13 +1617,15 @@ export class ExpertClaimService { async validateClaimFactorsV2( claimRequestId: string, body: FactorValidationV2Dto, - actor: { sub: string; fullName?: string }, + actor: { sub: string; fullName?: string; clientKey?: string }, ) { const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } + assertClaimCaseForTenant(claim, actor); + if ( claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL || claim.claimStatus !== ClaimStatus.UNDER_REVIEW || @@ -1842,12 +1859,15 @@ export class ExpertClaimService { * - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT */ async lockClaimRequestV2(claimRequestId: string, actor: any) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) { throw new BadRequestException( `Claim is not available for locking. Current status: ${claim.status}`, @@ -1913,6 +1933,8 @@ export class ExpertClaimService { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, @@ -1994,12 +2016,15 @@ export class ExpertClaimService { reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto, actor: any, ) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in a reviewable state. Current status: ${claim.status}`, @@ -2131,12 +2156,15 @@ export class ExpertClaimService { * - Unlocks the workflow so user can act */ async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, @@ -2179,12 +2207,15 @@ export class ExpertClaimService { /** * V2: Get claim list for damage expert * - * Returns claims that are: - * 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick) - * 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING) - * 3. Any status locked by THIS expert (their own in-progress work) + * Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`): + * 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue) + * 2. Locked by this expert (in-progress) + * 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue) */ - async getClaimListV2(actorId: string): Promise { + async getClaimListV2(actor: any): Promise { + requireActorClientKey(actor); + const actorId = actor.sub; + const clientKey = actor.clientKey as string; const claims = await this.claimCaseDbService.find({ $or: [ // Available claims: waiting for expert, not locked @@ -2206,34 +2237,36 @@ export class ExpertClaimService { ], }); - const list = (claims as any[]).map((c) => { - const awaitingFactorValidation = - c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && - c.claimStatus === ClaimStatus.UNDER_REVIEW && - c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; - return { - claimRequestId: c._id.toString(), - publicId: c.publicId, - status: c.status, - currentStep: c.workflow?.currentStep || '', - locked: c.workflow?.locked || false, - lockedBy: c.workflow?.lockedBy - ? { - actorId: c.workflow.lockedBy.actorId?.toString(), - actorName: c.workflow.lockedBy.actorName, - } - : undefined, - vehicle: c.vehicle - ? { - carName: c.vehicle.carName, - carModel: c.vehicle.carModel, - carType: c.vehicle.carType, - } - : undefined, - createdAt: c.createdAt, - awaitingFactorValidation, - }; - }) as ClaimListItemV2Dto[]; + const list = (claims as any[]) + .filter((c) => claimCaseTouchesClient(c, clientKey)) + .map((c) => { + const awaitingFactorValidation = + c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL && + c.claimStatus === ClaimStatus.UNDER_REVIEW && + c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION; + return { + claimRequestId: c._id.toString(), + publicId: c.publicId, + status: c.status, + currentStep: c.workflow?.currentStep || '', + locked: c.workflow?.locked || false, + lockedBy: c.workflow?.lockedBy + ? { + actorId: c.workflow.lockedBy.actorId?.toString(), + actorName: c.workflow.lockedBy.actorName, + } + : undefined, + vehicle: c.vehicle + ? { + carName: c.vehicle.carName, + carModel: c.vehicle.carModel, + carType: c.vehicle.carType, + } + : undefined, + createdAt: c.createdAt, + awaitingFactorValidation, + }; + }) as ClaimListItemV2Dto[]; return { list, total: list.length }; } @@ -2242,20 +2275,23 @@ export class ExpertClaimService { * V2: Get claim detail for damage expert * * Validations: - * - Claim must exist - * - Claim status must be WAITING_FOR_DAMAGE_EXPERT - * - If claim is locked, only the locking expert can access it + * - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`) + * - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) + * - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION */ async getClaimDetailV2( claimRequestId: string, - actorId: string, + actor: any, ): Promise { + const actorId = actor.sub; const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException('Claim request not found'); } + assertClaimCaseForTenant(claim, actor); + const isPickupReview = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT; const isFactorValidationPending = @@ -2377,10 +2413,12 @@ export class ExpertClaimService { body: UpdateClaimDamagedPartsV2Dto, actor: any, ) { + requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } + assertClaimCaseForTenant(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index 33b59cb..1ef92d2 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -41,7 +41,7 @@ export class ExpertClaimV2Controller { "Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).", }) async getClaimListV2(@CurrentUser() actor) { - return await this.expertClaimService.getClaimListV2(actor.sub); + return await this.expertClaimService.getClaimListV2(actor); } @Get("request/:claimRequestId") @@ -55,10 +55,7 @@ export class ExpertClaimV2Controller { @Param("claimRequestId") claimRequestId: string, @CurrentUser() actor, ) { - return await this.expertClaimService.getClaimDetailV2( - claimRequestId, - actor.sub, - ); + return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor); } @Put("lock/:claimRequestId") diff --git a/src/expert-insurer/expert-insurer.controller.ts b/src/expert-insurer/expert-insurer.controller.ts index 3dd0265..5808ac5 100644 --- a/src/expert-insurer/expert-insurer.controller.ts +++ b/src/expert-insurer/expert-insurer.controller.ts @@ -78,6 +78,7 @@ export class ExpertInsurerController { @ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Get("/:expertId") async requestDetail( + @CurrentUser() insurer, @Param("expertId") id: string, @Query("role") role: "claim" | "blame", ) { @@ -89,7 +90,11 @@ export class ExpertInsurerController { throw new BadRequestException("Invalid role"); } - return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role); + return await this.expertInsurerService.getAllFilesByExpertAndRole( + id, + role, + insurer.clientKey, + ); } @ApiBody({ @@ -153,6 +158,7 @@ export class ExpertInsurerController { @ApiQuery({ name: "role", enum: ["claim", "blame"] }) @Put("/:requestId/rating") async rateExperts( + @CurrentUser() insurer, @Param("requestId") requestId: string, @Body() rating: FileRating, @Query("role") role: "claim" | "blame", @@ -165,13 +171,13 @@ export class ExpertInsurerController { requestId, rating, role, + insurer.clientKey, ); } @Get("branches/:insuranceId") @ApiParam({ name: "insuranceId" }) async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { - console.log("insuranceId", insuranceId); return await this.expertInsurerService.retrieveInsuranceBranches( insuranceId, ); diff --git a/src/expert-insurer/expert-insurer.service.ts b/src/expert-insurer/expert-insurer.service.ts index 441967a..cda0370 100644 --- a/src/expert-insurer/expert-insurer.service.ts +++ b/src/expert-insurer/expert-insurer.service.ts @@ -1,163 +1,159 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, - Logger, NotFoundException, } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model, Types } from "mongoose"; -import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service"; -import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service"; -import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service"; -import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema"; +import { Types } from "mongoose"; +import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; import { CreateBranchDto } from "src/client/dto/create-branch.dto"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; -import { ClientDbService } from "src/client/entities/db-service/client.db.service"; -import { buildFileLink } from "src/helpers/urlCreator"; -import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; -import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; -import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; -import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service"; import { - FileRating, - RequestManagementModel, -} from "src/request-management/entities/schema/request-management.schema"; + blameCaseTouchesClient, + claimCaseTouchesClient, +} from "src/helpers/tenant-scope"; +import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; +import { FileRating } from "src/request-management/entities/schema/request-management.schema"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; @Injectable() export class ExpertInsurerService { - private readonly logger = new Logger(ExpertInsurerService.name); - constructor( - private readonly client: ClientDbService, private readonly expertDbService: ExpertDbService, private readonly damageExpertDbService: DamageExpertDbService, - @InjectModel(ClaimRequestManagementModel.name) - private readonly claimRequestManagementModel: Model, - @InjectModel(RequestManagementModel.name) - private readonly requestManagementModel: Model, - private readonly blameVoiceDbService: BlameVoiceDbService, - private readonly blameVideoDbService: BlameVideoDbService, - private readonly blameDocumentDbService: BlameDocumentDbService, - private readonly userSignDbService: UserSignDbService, - private readonly claimVideoCaptureDbService: VideoCaptureDbService, + private readonly blameRequestDbService: BlameRequestDbService, + private readonly claimCaseDbService: ClaimCaseDbService, private readonly branchDbService: BranchDbService, - private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, - private readonly damageImageDbService: DamageImageDbService, ) {} - async retrieveAllExpertsOfClient( - actor, - currentPage: number, - countPerPage: number, - ) { - const { clientKey } = actor; - if (!clientKey) return; + private getClientId(actorOrId: any): Types.ObjectId { + const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey; + if (!raw || !Types.ObjectId.isValid(raw)) { + throw new BadRequestException("Client key is required"); + } + return new Types.ObjectId(raw); + } - const clientObjectId = new Types.ObjectId(clientKey); + private parseObjectId(value: string, label: string): Types.ObjectId { + if (!Types.ObjectId.isValid(value)) { + throw new BadRequestException(`Invalid ${label}`); + } + return new Types.ObjectId(value); + } - const [experts, damageExperts] = await Promise.all([ - this.expertDbService.findAll({ clientKey: clientKey }), + private normalizeClaimCase(claim: any): any { + return { + ...claim, + requestNumber: claim.requestNo, + userClientKey: claim.owner?.userClientKey, + fullName: claim.owner?.fullName, + carDetail: claim.vehicle, + carPlate: claim.vehicle?.plate, + claimStatus: claim.status, + rating: claim.evaluation?.rating, + userRating: claim.evaluation?.userRating, + damageExpertReply: claim.evaluation?.damageExpertReply, + damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal, + damageExpertResend: claim.evaluation?.damageExpertResend, + objection: claim.evaluation?.objection, + userResendDocuments: claim.evaluation?.userResendDocuments, + priceDrop: claim.evaluation?.priceDrop, + visitLocation: claim.evaluation?.visitLocation, + currentStep: claim.workflow?.currentStep, + nextStep: claim.workflow?.nextStep, + }; + } + + private normalizeBlameCase(blame: any): any { + const firstParty = blame?.parties?.find((p) => p?.role === "FIRST"); + const secondParty = blame?.parties?.find((p) => p?.role === "SECOND"); + return { + ...blame, + requestNumber: blame.requestNo, + rating: blame?.expert?.rating, + actorLocked: { actorId: blame?.expert?.assignedExpertId }, + firstPartyDetails: { + firstPartyClient: { clientId: firstParty?.person?.clientId }, + }, + secondPartyDetails: { + secondPartyClient: { clientId: secondParty?.person?.clientId }, + }, + }; + } + + private getCombinedFileScore(file: any): number | null { + const insurerRating = file?.rating; + const userRating = file?.userRating; + const insurerValues = insurerRating + ? Object.values(insurerRating).filter( + (v): v is number => typeof v === "number" && !isNaN(v), + ) + : []; + const userValues = userRating + ? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter( + (v) => typeof v === "number" && !isNaN(v), + ) + : []; + const insurerAvg = insurerValues.length + ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length + : null; + const userAvg = userValues.length + ? userValues.reduce((a, b) => a + b, 0) / userValues.length + : null; + const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number"); + if (!scores.length) return null; + return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)); + } + + private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise { + const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; + const idStr = String(clientObjectId); + return all + .filter((f) => + (f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr), + ) + .filter( + (f) => + f?.status !== CaseStatus.OPEN && + f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY, + ) + .map((f) => this.normalizeBlameCase(f)); + } + + private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise { + const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; + const idStr = String(clientObjectId); + return all + .filter((f) => String(f?.owner?.userClientKey || "") === idStr) + .map((f) => this.normalizeClaimCase(f)); + } + + async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) { + const clientObjectId = this.getClientId(actor); + const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([ + this.expertDbService.findAll({ clientKey: String(clientObjectId) }), this.damageExpertDbService.findAll({ clientKey: clientObjectId }), + this.getClientBlameFiles(clientObjectId), + this.getClientClaimFiles(clientObjectId), ]); + const allExpertsRaw = [...experts, ...damageExperts]; - const expertIds = allExpertsRaw.map((expert) => expert._id.toString()); - - if (expertIds.length === 0) - return { total: 0, page: currentPage, countPerPage, experts: [] }; - - const expertObjectIds = expertIds.map((id) => new Types.ObjectId(id)); - - const blameFileQuery = { - $and: [ - { - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientObjectId }, - { "secondPartyDetails.secondPartyClient.clientId": clientObjectId }, - ], - }, - { "actorLocked.actorId": { $in: expertObjectIds } }, - ], - }; - - const claimFileQuery = { - $and: [ - { userClientKey: clientObjectId }, - { "damageExpertReply.actorDetail.actorId": { $in: expertIds } }, - ], - }; - - const [blameFiles, claimFiles] = await Promise.all([ - this.requestManagementModel.find(blameFileQuery).lean(), - this.claimRequestManagementModel.find(claimFileQuery).lean(), - ]); - const expertTotalRatingsMap: Record = {}; - const expertRatingsByCategoryMap: Record< - string, - Record - > = {}; + const expertRatingsByCategoryMap: Record> = {}; - /** - * Converts insurer FileRating and optional userRating into a single - * combined score for a file. - */ - const getCombinedFileScore = (file: any): number | null => { - const insurerRating = file?.rating; - const userRating = file?.userRating; - - const insurerValues = insurerRating - ? Object.values(insurerRating).filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ) - : []; - const insurerAvg = - insurerValues.length > 0 - ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length - : null; - - const userValues = userRating - ? [ - userRating.progressSpeed, - userRating.registrationEase, - userRating.overallEvaluation, - ].filter((val) => typeof val === "number" && !isNaN(val)) - : []; - const userAvg = - userValues.length > 0 - ? userValues.reduce((a, b) => a + b, 0) / userValues.length - : null; - - const scores = [insurerAvg, userAvg].filter( - (v): v is number => typeof v === "number" && !isNaN(v), - ); - if (scores.length === 0) return null; - return parseFloat( - (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2), - ); - }; - - const processRatings = (expertId: string, file: any) => { + const processRatings = (expertId: string | undefined, file: any) => { if (!expertId) return; - const rating = file?.rating; - const combinedScore = getCombinedFileScore(file); - - // Aggregate overall combined score for this expert + const combinedScore = this.getCombinedFileScore(file); if (combinedScore !== null) { - if (!expertTotalRatingsMap[expertId]) - expertTotalRatingsMap[expertId] = []; + if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = []; expertTotalRatingsMap[expertId].push(combinedScore); } - - // Keep backward-compatible per-category insurer ratings if (!rating || typeof rating !== "object") return; - - if (!expertRatingsByCategoryMap[expertId]) { - expertRatingsByCategoryMap[expertId] = {}; - } + if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {}; for (const [category, value] of Object.entries(rating)) { if (typeof value === "number" && !isNaN(value)) { if (!expertRatingsByCategoryMap[expertId][category]) { @@ -169,37 +165,29 @@ export class ExpertInsurerService { }; for (const file of blameFiles) { - const expertId = file?.actorLocked?.actorId?.toString(); - processRatings(expertId, file); + processRatings(file?.actorLocked?.actorId?.toString?.(), file); } - for (const file of claimFiles) { - const expertId = file?.damageExpertReply?.actorDetail?.actorId; - processRatings(expertId, file); + processRatings(file?.damageExpertReply?.actorDetail?.actorId, file); } const allExperts = allExpertsRaw.map((expert) => { const expertIdStr = expert._id.toString(); - const totalRatings = expertTotalRatingsMap[expertIdStr] || []; const overallAverageRating = totalRatings.length ? parseFloat( - ( - totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length - ).toFixed(2), + (totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2), ) : null; - const averageRatingsByCategory: Record = {}; const ratingsByCat = expertRatingsByCategoryMap[expertIdStr]; if (ratingsByCat) { for (const [category, values] of Object.entries(ratingsByCat)) { - const sum = values.reduce((a, b) => a + b, 0); - const average = parseFloat((sum / values.length).toFixed(2)); - averageRatingsByCategory[category] = average; + averageRatingsByCategory[category] = parseFloat( + (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2), + ); } } - return { _id: expert._id, fullName: `${expert.firstName} ${expert.lastName}`, @@ -212,14 +200,14 @@ export class ExpertInsurerService { }; }); - const start = (currentPage - 1) * countPerPage; - const paginated = allExperts.slice(start, start + countPerPage); - + const page = Number(currentPage) > 0 ? Number(currentPage) : 1; + const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20; + const start = (page - 1) * perPage; return { total: allExperts.length, - page: currentPage, - countPerPage, - experts: paginated, + page, + countPerPage: perPage, + experts: allExperts.slice(start, start + perPage), }; } @@ -245,510 +233,125 @@ export class ExpertInsurerService { * combined insurer + user ratings. */ async getTopFilesForClient(insurerId: string): Promise { - const id = new Types.ObjectId(insurerId); - - const claimFiles = await this.claimRequestManagementModel - .find( - { - userClientKey: id, - }, - { - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - carPlate: 1, - claimStatus: 1, - rating: 1, - userRating: 1, - damageExpertReply: 1, - damageExpertReplyFinal: 1, - createdAt: 1, - }, - ) - .lean(); - + const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId)); const scored = claimFiles .map((file) => { - const insurerRating = file?.rating; - const userRating = file?.userRating; - - const insurerValues = insurerRating - ? Object.values(insurerRating).filter( - (val): val is number => typeof val === "number" && !isNaN(val), - ) - : []; - const insurerAvg = - insurerValues.length > 0 - ? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length - : null; - - const userValues = userRating - ? [ - userRating.progressSpeed, - userRating.registrationEase, - userRating.overallEvaluation, - ].filter((val) => typeof val === "number" && !isNaN(val)) - : []; - const userAvg = - userValues.length > 0 - ? userValues.reduce((a, b) => a + b, 0) / userValues.length - : null; - - const scores = [insurerAvg, userAvg].filter( - (v): v is number => typeof v === "number" && !isNaN(v), - ); - if (scores.length === 0) return null; - - const combinedScore = - scores.reduce((a, b) => a + b, 0) / scores.length; - - return { - ...file, - combinedScore: parseFloat(combinedScore.toFixed(2)), - }; + const combinedScore = this.getCombinedFileScore(file); + if (combinedScore === null) return null; + return { ...file, combinedScore }; }) .filter((f) => f !== null); - - const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore); - return sorted.slice(0, 10); + return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10); } - async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") { - const expertObjectId = new Types.ObjectId(expertId); - - const model = - role === "claim" - ? this.claimRequestManagementModel - : this.requestManagementModel; - const expertCollection = role === "claim" ? "damage-expert" : "expert"; - - const results = await model.aggregate([ - { $match: { "actorLocked.actorId": expertObjectId } }, - { - $lookup: { - from: expertCollection, - localField: "actorLocked.actorId", - foreignField: "_id", - as: "expertInfo", - }, - }, - { $unwind: { path: "$expertInfo", preserveNullAndEmptyArrays: true } }, - - { - $addFields: { - averageRating: { - $cond: [ - { $ne: ["$rating", null] }, - { - $avg: [ - "$rating.collisionMethodAccuracy", - "$rating.evaluationTimeliness", - "$rating.accidentCauseAccuracy", - "$rating.guiltyVehicleIdentification", - ], - }, - null, - ], - }, - }, - }, - - { - $project: { - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - claimStatus: 1, - steps: 1, - currentStep: 1, - rating: 1, - averageRating: 1, - imageRequired: 1, - expertInfo: { - _id: 1, - fullName: { - $concat: ["$expertInfo.firstName", " ", "$expertInfo.lastName"], - }, - requestStats: 1, - userType: 1, - createdAt: 1, - }, - }, - }, + private async assertExpertBelongsToInsurer( + expertObjectId: Types.ObjectId, + insurerClientKey: string, + ) { + const ck = String(insurerClientKey); + const clientOid = new Types.ObjectId(ck); + const [experts, damageExperts] = await Promise.all([ + this.expertDbService.findAll({ clientKey: ck }), + this.damageExpertDbService.findAll({ clientKey: clientOid }), ]); - - // Process imageRequired for claim files - if (role === "claim") { - return await Promise.all( - results.map((file) => this.processImageRequired(file)), + const id = String(expertObjectId); + const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id); + if (!ok) { + throw new ForbiddenException( + "This expert is not registered under your insurance company.", ); } + } - return results; + async getAllFilesByExpertAndRole( + expertId: string, + role: "claim" | "blame", + insurerClientKey: string, + ) { + const expertObjectId = this.parseObjectId(expertId, "expert id"); + await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey); + const clientKeyStr = String(this.getClientId(insurerClientKey)); + + if (role === "claim") { + const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[]; + return claims + .filter( + (c) => + claimCaseTouchesClient(c, clientKeyStr) && + c?.evaluation?.damageExpertReply?.actorDetail?.actorId === + String(expertObjectId), + ) + .map((c) => this.normalizeClaimCase(c)); + } + const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[]; + return blames + .filter( + (b) => + blameCaseTouchesClient(b, clientKeyStr) && + String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId), + ) + .map((b) => this.normalizeBlameCase(b)); } async rateExpertOnFile( requestId: string, rating: FileRating, role: "claim" | "blame", + insurerClientKey: string, ) { - const _id = new Types.ObjectId(requestId); - - // Validate each rating factor - const fields = Object.entries(rating); - for (const [key, value] of fields) { + const _id = this.parseObjectId(requestId, "request id"); + const clientKeyStr = String(this.getClientId(insurerClientKey)); + for (const [key, value] of Object.entries(rating || {})) { if (typeof value !== "number" || value < 0 || value > 5) { - throw new BadRequestException( - `${key} must be a number between 0 and 5`, - ); + throw new BadRequestException(`${key} must be a number between 0 and 5`); } } - if (role === "claim") { - const updated = await this.claimRequestManagementModel.findByIdAndUpdate( - _id, - { $set: { rating } }, - { new: true }, - ); - - if (!updated) { - throw new NotFoundException("Claim file not found"); + const existing = await this.claimCaseDbService.findById(_id); + if (!existing) throw new NotFoundException("Claim file not found"); + if (!claimCaseTouchesClient(existing as any, clientKeyStr)) { + throw new ForbiddenException("This claim does not belong to your organization."); } - + const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, { + $set: { "evaluation.rating": rating }, + }); + if (!updated) throw new NotFoundException("Claim file not found"); return { message: "Claim expert rated successfully", - updatedRating: updated.rating, + updatedRating: (updated as any)?.evaluation?.rating || rating, requestId: updated._id, }; } - if (role === "blame") { - const updated = await this.requestManagementModel.findByIdAndUpdate( - _id, - { $set: { rating } }, - { new: true }, - ); - - if (!updated) { - throw new NotFoundException("Blame file not found"); + const existing = await this.blameRequestDbService.findById(_id); + if (!existing) throw new NotFoundException("Blame file not found"); + if (!blameCaseTouchesClient(existing as any, clientKeyStr)) { + throw new ForbiddenException("This blame case does not belong to your organization."); } - + const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, { + $set: { "expert.rating": rating }, + }); + if (!updated) throw new NotFoundException("Blame file not found"); return { message: "Blame expert rated successfully", - updatedRating: updated.rating, + updatedRating: (updated as any)?.expert?.rating || rating, requestId: updated._id, }; } - throw new BadRequestException("Invalid role"); } async retrieveAllFilesOfClient(insurerId: string) { - const id = new Types.ObjectId(insurerId); - - const blameFilesRaw = await this.requestManagementModel - .find({ - "firstPartyDetails.firstPartyClient.clientId": id, - blameStatus: { - $nin: ["PendingForFirstParty", "PendingForSecondParty"], - }, - }) - .lean(); - - const claimFilesRaw = await this.claimRequestManagementModel - .find({ - userClientKey: id, - }, { - _id: 1, - requestNumber: 1, - userClientKey: 1, - userId: 1, - fullName: 1, - carDetail: 1, - carPlate: 1, - claimStatus: 1, - currentStep: 1, - nextStep: 1, - carPartDamage: 1, - otherParts: 1, - sheba: 1, - nationalCodeOfInsurer: 1, - carGreenCard: 1, - aiImages: 1, - imageRequired: 1, - videoCaptureId: 1, - damageExpertReply: 1, - damageExpertReplyFinal: 1, - damageExpertResend: 1, - objection: 1, - userResendDocuments: 1, - priceDrop: 1, - requiredDocuments: 1, - createdAt: 1, - updatedAt: 1, - rating: 1, - visitLocation: 1, - }) - .lean(); - - const populatedBlameFiles = await Promise.all( - blameFilesRaw.map((file) => this.populateBlameFileLinks(file)), - ); - const populatedClaimFiles = await Promise.all( - claimFilesRaw.map(async (file) => { - const populated = await this.populateClaimFileLinks(file); - return await this.processImageRequired(populated); - }), - ); - - return { blameFiles: populatedBlameFiles, claimFiles: populatedClaimFiles }; - } - - private async populateBlameFileLinks(blameFile: any): Promise { - if (!blameFile) return blameFile; - - // --- FIX: Consistently use findById --- - if (blameFile.firstPartyDetails?.firstPartyFile?.firstPartyVideoId) { - const videoDoc = await this.blameVideoDbService.findById( - blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId.toString(), - ); - if (videoDoc) - blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId = - buildFileLink(videoDoc.path); - } - if (Array.isArray(blameFile.firstPartyDetails?.firstPartyFile?.voices)) { - blameFile.firstPartyDetails.firstPartyFile.voices = - await this.populateIdArray( - this.blameVoiceDbService, - blameFile.firstPartyDetails.firstPartyFile.voices, - ); - } - if (Array.isArray(blameFile.secondPartyDetails?.secondPartyFiles?.voices)) { - blameFile.secondPartyDetails.secondPartyFiles.voices = - await this.populateIdArray( - this.blameVoiceDbService, - blameFile.secondPartyDetails.secondPartyFiles.voices, - ); - } - - if (blameFile.expertResendReply) { - await this.populatePartyReplyLinks( - blameFile.expertResendReply.firstParty, - ); - await this.populatePartyReplyLinks( - blameFile.expertResendReply.secondParty, - ); - } - - const finalReply = - blameFile.expertSubmitReplyFinal || blameFile.expertSubmitReply; - if (finalReply) { - await this.populateSignatureLink(finalReply.firstPartyComment); - await this.populateSignatureLink(finalReply.secondPartyComment); - } - - return blameFile; - } - - private async populateClaimFileLinks(claimFile: any): Promise { - if (!claimFile) return claimFile; - - if (claimFile.blameFile) { - claimFile.blameFile = await this.populateBlameFileLinks( - claimFile.blameFile, - ); - } - - // --- FIX: Consistently use findById --- - if (claimFile.videoCaptureId) { - const videoDoc = await this.claimVideoCaptureDbService.findById( - claimFile.videoCaptureId._id.toString(), - ); - if (videoDoc) claimFile.videoCaptureId = buildFileLink(videoDoc.path); - } - - if (claimFile.requiredDocuments) { - const documents = await this.claimRequiredDocumentDbService.findByClaimId( - claimFile._id.toString(), - ); - - // Populate with file URLs - const populatedDocuments = documents.map((doc) => ({ - _id: doc._id, - documentType: doc.documentType, - fileName: doc.fileName, - fileUrl: buildFileLink(doc.path), - uploadedAt: doc.uploadedAt, - })); - - claimFile.requiredDocuments = populatedDocuments; - } - - return claimFile; - } - - /** - * Processes imageRequired field: - * 1. Removes part_segments from aiReport.distinct_damaged_parts_report.parts[] - * 2. Populates imageId fields with file links - */ - private async processImageRequired(claimFile: any): Promise { - if (!claimFile || !claimFile.imageRequired) { - return claimFile; - } - - const imageRequired = claimFile.imageRequired; - - // Process aroundTheCar array - if (Array.isArray(imageRequired.aroundTheCar)) { - imageRequired.aroundTheCar = await Promise.all( - imageRequired.aroundTheCar.map(async (item: any) => { - // Remove part_segments from aiReport.distinct_damaged_parts_report.parts[] - if ( - item?.aiReport?.distinct_damaged_parts_report?.parts && - Array.isArray(item.aiReport.distinct_damaged_parts_report.parts) - ) { - item.aiReport.distinct_damaged_parts_report.parts = - item.aiReport.distinct_damaged_parts_report.parts.map( - (part: any) => { - const { part_segments, ...partWithoutSegments } = part; - return partWithoutSegments; - }, - ); - } - - // Populate imageId with file link - if (item?.imageId) { - try { - const imageDoc = await this.damageImageDbService.findOne( - item.imageId.toString(), - ); - if (imageDoc && imageDoc.path) { - item.imageId = buildFileLink(imageDoc.path); - } - } catch (error) { - this.logger.warn( - `Failed to populate imageId for aroundTheCar item: ${error.message}`, - ); - } - } - - return item; - }), - ); - } - - // Process selectPartOfCar array - if (Array.isArray(imageRequired.selectPartOfCar)) { - imageRequired.selectPartOfCar = await Promise.all( - imageRequired.selectPartOfCar.map(async (item: any) => { - // Remove part_segments from aiReport.distinct_damaged_parts_report.parts[] - if ( - item?.aiReport?.distinct_damaged_parts_report?.parts && - Array.isArray(item.aiReport.distinct_damaged_parts_report.parts) - ) { - item.aiReport.distinct_damaged_parts_report.parts = - item.aiReport.distinct_damaged_parts_report.parts.map( - (part: any) => { - const { part_segments, ...partWithoutSegments } = part; - return partWithoutSegments; - }, - ); - } - - // Populate imageId with file link - if (item?.imageId) { - try { - const imageDoc = await this.damageImageDbService.findOne( - item.imageId.toString(), - ); - if (imageDoc && imageDoc.path) { - item.imageId = buildFileLink(imageDoc.path); - } - } catch (error) { - this.logger.warn( - `Failed to populate imageId for selectPartOfCar item: ${error.message}`, - ); - } - } - - return item; - }), - ); - } - - return claimFile; - } - - private async populatePartyReplyLinks(partyReply: any) { - if (!partyReply) return; - // --- FIX: Consistently use findById --- - if (partyReply.voice) { - const voiceDoc = await this.blameVoiceDbService.findById( - partyReply.voice.toString(), - ); - if (voiceDoc) partyReply.voice = buildFileLink(voiceDoc.path); - } - if (partyReply.documents) { - for (const docType in partyReply.documents) { - const docId = partyReply.documents[docType]; - if (docId) { - const doc = await this.blameDocumentDbService.findById( - docId.toString(), - ); - if (doc) partyReply.documents[docType] = buildFileLink(doc.path); - } - } - } - } - - private async populateSignatureLink(comment: any) { - if (comment?.signDetail?.fileId) { - // --- FIX: Consistently use findById --- - const signDoc = await this.userSignDbService.findById( - comment.signDetail.fileId.toString(), - ); - if (signDoc) - (comment.signDetail as any).fileUrl = buildFileLink(signDoc.path); - } - } - - private async populateIdArray(dbService: any, ids: any[]): Promise { - return Promise.all( - ids.map(async (id) => { - if (!id) return id; - - if (typeof id === "object" && id.path) { - return buildFileLink(id.path); - } - - const idString = id.toString(); - if (idString.includes("[object Object]")) { - this.logger.warn( - "Invalid object found in ID array, skipping population.", - id, - ); - return id; - } - - try { - // --- FIX: Consistently use findById --- - const doc = await dbService.findById(idString); - return doc ? buildFileLink(doc.path) : id; - } catch (error) { - this.logger.error(`Failed to populate ID: ${idString}`, error); - return id; - } - }), - ); + const id = this.getClientId(insurerId); + const [blameFiles, claimFiles] = await Promise.all([ + this.getClientBlameFiles(id), + this.getClientClaimFiles(id), + ]); + return { blameFiles, claimFiles }; } async addBranch(clientKey: string, branchDto: CreateBranchDto) { - const clientId = new Types.ObjectId(clientKey); + const clientId = this.getClientId(clientKey); const existingBranch = await this.branchDbService.findOne({ clientKey: clientId, @@ -778,19 +381,8 @@ export class ExpertInsurerService { * - Number of files created in the current month */ async getExpertStatisticsReport(actor: any) { - const { clientKey } = actor; - if (!clientKey) { - throw new BadRequestException("Client key is required"); - } - - const clientObjectId = new Types.ObjectId(clientKey); - - // Get all claim files for this client - const claimFiles = await this.claimRequestManagementModel - .find({ - userClientKey: clientObjectId, - }) - .lean(); + const clientObjectId = this.getClientId(actor); + const claimFiles = await this.getClientClaimFiles(clientObjectId); // Calculate current month date range const now = new Date(); @@ -907,6 +499,6 @@ export class ExpertInsurerService { } async retrieveInsuranceBranches(insuranceId: string) { - return await this.branchDbService.findAll(insuranceId); + return this.branchDbService.findAll(insuranceId); } } diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts new file mode 100644 index 0000000..b87bc91 --- /dev/null +++ b/src/helpers/tenant-scope.ts @@ -0,0 +1,66 @@ +import { BadRequestException, ForbiddenException } from "@nestjs/common"; + +/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */ +export function requireActorClientKey(actor: { clientKey?: string }): string { + const ck = actor?.clientKey; + if (!ck || typeof ck !== "string") { + throw new BadRequestException( + "Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.", + ); + } + return ck; +} + +export function blameCaseTouchesClient(doc: any, clientKey: string): boolean { + const id = String(clientKey); + return (doc?.parties ?? []).some( + (p: any) => String(p?.person?.clientId ?? "") === id, + ); +} + +export function claimCaseTouchesClient(doc: any, clientKey: string): boolean { + return String(doc?.owner?.userClientKey ?? "") === String(clientKey); +} + +/** + * Field expert–initiated cases may not yet have party clientId filled; the + * initiating expert is always scoped to their insurer via JWT. + */ +export function blameCaseAccessibleToExpert( + doc: any, + actor: { sub: string; clientKey?: string }, +): boolean { + if ( + doc.expertInitiated && + doc.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) === actor.sub + ) { + return true; + } + const ck = actor?.clientKey; + if (!ck) return false; + return blameCaseTouchesClient(doc, ck); +} + +export function assertBlameCaseForExpertTenant( + doc: any, + actor: { sub: string; clientKey?: string }, +): void { + if (!blameCaseAccessibleToExpert(doc, actor)) { + throw new ForbiddenException( + "This blame case does not belong to your organization.", + ); + } +} + +export function assertClaimCaseForTenant( + doc: any, + actor: { clientKey?: string }, +): void { + const ck = requireActorClientKey(actor); + if (!claimCaseTouchesClient(doc, ck)) { + throw new ForbiddenException( + "This claim does not belong to your organization.", + ); + } +} diff --git a/src/reports/dto/reports.dto.ts b/src/reports/dto/reports.dto.ts index a9e382e..8864f19 100644 --- a/src/reports/dto/reports.dto.ts +++ b/src/reports/dto/reports.dto.ts @@ -1,50 +1,28 @@ -export class DamageExpertAllRequestsCountReportDtoRs { - all: number; - UnChecked: number; - CheckedRequest: number; - CheckAgain: number; - WaitingForUserToResend: number; - CloseRequest: number; - WaitingForUserCompleted: number; - InPersonVisit: number; +/** + * Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`), + * plus `all` = total matching documents for the tenant. + */ +export class BlameCaseStatusCountReportDtoRs { + [key: string]: number; - constructor(report: any) { - this.all = report.all; - this.UnChecked = report.UnChecked; - this.CheckedRequest = report.CheckedRequest; - this.CheckAgain = report.CheckAgain; - this.WaitingForUserToResend = report.WaitingForUserToResend; - this.CloseRequest = report.CloseRequest; - this.WaitingForUserCompleted = report.WaitingForUserCompleted; - this.InPersonVisit = report.InPersonVisit; + constructor(report: Record) { + Object.assign(this, report); } } -export class ExpertAllRequestsCountReportDtoRs { - all: number; - UnChecked: number; - CheckedRequest: number; - CheckAgain: number; - WaitingForUserToResend: number; - CloseRequest: number; - WaitingForUserCompleted: number; +export class ClaimCaseStatusCountReportDtoRs { + [key: string]: number; - constructor(report: any) { - this.all = report.all; - this.UnChecked = report.UnChecked; - this.CheckedRequest = report.CheckedRequest; - this.CheckAgain = report.CheckAgain; - this.WaitingForUserToResend = report.WaitingForUserToResend; - this.CloseRequest = report.CloseRequest; - this.WaitingForUserCompleted = report.WaitingForUserCompleted; + constructor(report: Record) { + Object.assign(this, report); } } export class CompanyAllRequestsCountReportDtoRs { - blame: object; - claim: object; + blame: Record; + claim: Record; - constructor(blame: object, claim: object) { + constructor(blame: Record, claim: Record) { this.blame = blame; this.claim = claim; } diff --git a/src/reports/reports.module.ts b/src/reports/reports.module.ts index 0fb42c3..c735eb3 100644 --- a/src/reports/reports.module.ts +++ b/src/reports/reports.module.ts @@ -1,7 +1,6 @@ import { Module } from "@nestjs/common"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module"; -import { ClientModule } from "src/client/client.module"; import { ReportsController } from "./reports.controller"; import { ReportsService } from "./reports.service"; @@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service"; imports: [ RequestManagementModule, ClaimRequestManagementModule, - ClientModule, ], controllers: [ReportsController], providers: [ReportsService], diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index e34a808..c82e782 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -1,413 +1,243 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { Types } from "mongoose"; -import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; -import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; -import { ClientDbService } from "src/client/entities/db-service/client.db.service"; -import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; -import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum"; -import { UserType } from "src/Types&Enums/userType.enum"; +import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service"; +import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; +import { requireActorClientKey } from "src/helpers/tenant-scope"; import { + BlameCaseStatusCountReportDtoRs, + ClaimCaseStatusCountReportDtoRs, CompanyAllRequestsCountReportDtoRs, - DamageExpertAllRequestsCountReportDtoRs, - ExpertAllRequestsCountReportDtoRs, } from "./dto/reports.dto"; @Injectable() export class ReportsService { - private readonly logger = new Logger(ReportsService.name); - constructor( - private readonly requestManagementDbService: RequestManagementDbService, - private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, - private readonly clientDbService: ClientDbService, + private readonly blameRequestDbService: BlameRequestDbService, + private readonly claimCaseDbService: ClaimCaseDbService, ) {} - async getAllCheckedRequestsCountFn(role: string, client: string) { - const statuses = ["UnChecked", "CheckedRequest"]; - const data: Record = { all: 0 }; - const clientId = new Types.ObjectId(client); - - for (const status of statuses) { - let count = 0; - if (role === "damage_expert") { - count = await this.claimRequestManagementDbService.countByFilter({ - claimStatus: status, - userClientKey: clientId, - }); - } else { - count = await this.requestManagementDbService.countByFilter({ - blameStatus: status, - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientId }, - { "secondPartyDetails.secondPartyClient.clientId": clientId }, - ], - }); - } - data[status] = count; - data.all += count; - } - return data; + private clientObjectId(client: string | undefined): Types.ObjectId { + const ck = requireActorClientKey({ clientKey: client }); + return new Types.ObjectId(ck); } - async getDateFilteredRequestByStatus( - role: string, - client: string, - start: Date, - end: Date, - ) { - const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"]; - const data: Record = {}; - const clientId = new Types.ObjectId(client); - - for (const status of statuses) { - let blameCount = 0; - let claimCount = 0; - - if (role === "expert" || role === "company") { - blameCount = await this.requestManagementDbService.countByFilter({ - blameStatus: status, - createdAt: { $gte: start, $lte: end }, - $or: [ - { "firstPartyDetails.firstPartyClient.clientId": clientId }, - { "secondPartyDetails.secondPartyClient.clientId": clientId }, - ], - }); - } - - if (role === "damage_expert" || role === "company") { - claimCount = await this.claimRequestManagementDbService.countByFilter({ - claimStatus: status, - userClientKey: clientId, - createdAt: { $gte: start, $lte: end }, - }); - } - data[status] = blameCount + claimCount; - } - return data; - } - - private isVisibleToClientType(client: any, actor: any): boolean { - if (actor.userType === UserType.GENUINE) { - return true; - } - if ( - actor.userType === UserType.LEGAL && - String(client._id) === actor.clientKey - ) { - return true; - } - return false; - } - - private wasHandledByActor(request: any, actorSub: string): boolean { - type ActorCheckerEntry = { CheckedRequest?: { actorId: string } }; - const actorChecker = request.actorsChecker as ActorCheckerEntry[]; - - if (!Array.isArray(actorChecker)) { - return false; - } - - const matchingEntry = actorChecker.find( - (entry) => String(entry?.CheckedRequest?.actorId) === actorSub, + private isBlameForClient(r: any, client: Types.ObjectId): boolean { + const idStr = String(client); + return (r?.parties || []).some( + (p) => String(p?.person?.clientId || "") === idStr, ); - - return !!matchingEntry; } - /** - * Filters claim requests using the same logic as expert-claim service - * to ensure consistency between endpoints - */ - private async filterClaimRequestsForExpert( - requests: any[], - actor: any, - ): Promise { - const filteredRequests = []; + private isClaimForClient(r: any, client: Types.ObjectId): boolean { + return String(r?.owner?.userClientKey || "") === String(client); + } - for (const r of requests) { - // For expert-initiated blame files, only show to the initiating expert - if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) { - if (String(r.blameFile.initiatedBy) !== actor.sub) { - continue; // Skip if not the initiating expert - } - // Expert-initiated claim files are always visible to the initiating expert - filteredRequests.push(r); - continue; - } - - const client = await this.clientDbService.findOne({ - _id: r.userClientKey, - }); - - if (!client) { - this.logger.warn( - `Client not found for claim request with ID: ${r._id}. Skipping.`, - ); - continue; - } - - const specialHandlingStatuses = [ - ReqClaimStatus.CheckAgain, - ReqClaimStatus.ReviewRequest, - ReqClaimStatus.PendingFactorValidation, - ]; - - const requiresSpecificActorCheck = specialHandlingStatuses.includes( - r.claimStatus, - ); - - if (requiresSpecificActorCheck) { - if (this.wasHandledByActor(r, actor.sub)) { - filteredRequests.push(r); - } - } else { - if (this.isVisibleToClientType(client, actor)) { - filteredRequests.push(r); - } - } + private countBlameByCaseStatus( + blames: any[], + clientId: Types.ObjectId, + ): Record { + const out: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) { + out[s] = 0; } - - return filteredRequests; + for (const r of blames) { + if (!this.isBlameForClient(r, clientId)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; } - async getAllRequestsCountByRole(role: string, client: string, actor?: any) { + private countClaimByCaseStatus( + claims: any[], + clientId: Types.ObjectId, + ): Record { + const out: Record = { all: 0 }; + for (const s of Object.values(ClaimCaseStatus)) { + out[s] = 0; + } + for (const r of claims) { + if (!this.isClaimForClient(r, clientId)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + async getAllRequestsCountByRole(role: string, client: string | undefined) { + const clientId = this.clientObjectId(client); + const [blames, claims] = await Promise.all([ + this.blameRequestDbService.find({}, { lean: true }), + this.claimCaseDbService.find({}, { lean: true }), + ]); + if (role === "expert") { - const statuses = Object.values(ReqBlameStatus); - const data: Record = { all: 0 }; - - for (const status of statuses) { - const filter = { - blameStatus: status, - $or: [ - { - "firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId( - client, - ), - }, - // { - // "secondPartyDetails.secondPartyClient.clientId": - // new Types.ObjectId(client), - // }, - ], - }; - - const count = - await this.requestManagementDbService.countByFilter(filter); - data[status] = count; - data.all += count; - } - - return data; + return this.countBlameByCaseStatus(blames as any[], clientId); } if (role === "damage_expert") { - const statuses = Object.values(ReqClaimStatus); - const data: Record = { all: 0 }; - - // For damage_expert, we need to apply the same filtering as expert-claim service - if (actor) { - // Fetch all requests with the statuses that expert-claim service shows - // This matches the statuses in getClaimRequestsListForExpert - const relevantStatuses = [ - ReqClaimStatus.UnChecked, - ReqClaimStatus.ReviewRequest, - ReqClaimStatus.CheckAgain, - ReqClaimStatus.CloseRequest, - ReqClaimStatus.InPersonVisit, - ReqClaimStatus.CheckedRequest, - ReqClaimStatus.PendingFactorValidation, - ]; - - // Fetch all requests with relevant statuses (matching expert-claim query) - const allRequests = - await this.claimRequestManagementDbService.findAllByStatus({ - claimStatus: { $in: relevantStatuses }, - }); - - // Filter requests using the same logic as expert-claim service - const filteredRequests = - await this.filterClaimRequestsForExpert(allRequests, actor); - - // Count by status from filtered results - for (const status of statuses) { - const count = filteredRequests.filter( - (r) => r.claimStatus === status, - ).length; - data[status] = count; - data.all += count; - } - } else { - // Fallback to simple count if actor not provided (shouldn't happen for damage_expert) - for (const status of statuses) { - const filter = { - claimStatus: status, - userClientKey: new Types.ObjectId(client), - }; - - const count = - await this.claimRequestManagementDbService.countByFilter(filter); - data[status] = count; - data.all += count; - } - } - - return data; + return this.countClaimByCaseStatus(claims as any[], clientId); } - // ✅ Company logic if (role === "company") { - const blameStatuses = Object.values(ReqBlameStatus); - const claimStatuses = Object.values(ReqClaimStatus); - - const blameData: Record = { all: 0 }; - const claimData: Record = { all: 0 }; - - // Only count files where this company is FIRST party in Blame - for (const status of blameStatuses) { - const filter = { - blameStatus: status, - "firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId( - client, - ), - }; - - const count = - await this.requestManagementDbService.countByFilter(filter); - blameData[status] = count; - blameData.all += count; - } - - // Claims always use `userClientKey` - for (const status of claimStatuses) { - const filter = { - claimStatus: status, - userClientKey: new Types.ObjectId(client), - }; - - const count = - await this.claimRequestManagementDbService.countByFilter(filter); - claimData[status] = count; - claimData.all += count; - } - - return { blameData, claimData }; + return { + blame: this.countBlameByCaseStatus(blames as any[], clientId), + claim: this.countClaimByCaseStatus(claims as any[], clientId), + }; } return null; } - async getAllRequestsReportCount(actor, client) { - if (actor.role === "damage_expert") { - // Pass actor to apply filtering logic for damage_expert - const data = await this.getAllRequestsCountByRole( - actor.role, - client, - actor, - ); - return new DamageExpertAllRequestsCountReportDtoRs(data); - } else if (actor.role === "expert") { - const data = await this.getAllRequestsCountByRole(actor.role, client); - return new ExpertAllRequestsCountReportDtoRs(data); - } else { - // company - const damageExpertData = await this.getAllRequestsCountByRole( - "damage_expert", - client, - ); - const expertData = await this.getAllRequestsCountByRole("expert", client); - return new CompanyAllRequestsCountReportDtoRs( - expertData, - damageExpertData, - ); + /** + * Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`. + * Company gets both blame and claim maps; expert / damage_expert get one map. + */ + async getDateFilteredRequestByStatus( + role: string, + client: string | undefined, + start: Date, + end: Date, + ) { + const clientId = this.clientObjectId(client); + const [blames, claims] = await Promise.all([ + this.blameRequestDbService.find({}, { lean: true }), + this.claimCaseDbService.find({}, { lean: true }), + ]); + + const inRange = (r: any) => { + const createdAt = new Date(r.createdAt); + return createdAt >= start && createdAt <= end; + }; + + if (role === "company") { + const blameOut: Record = { all: 0 }; + const claimOut: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) blameOut[s] = 0; + for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0; + + for (const r of blames as any[]) { + if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in blameOut) blameOut[st]++; + else blameOut[st] = (blameOut[st] ?? 0) + 1; + blameOut.all++; + } + for (const r of claims as any[]) { + if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in claimOut) claimOut[st]++; + else claimOut[st] = (claimOut[st] ?? 0) + 1; + claimOut.all++; + } + return { blame: blameOut, claim: claimOut }; } + + if (role === "expert") { + const out: Record = { all: 0 }; + for (const s of Object.values(CaseStatus)) out[s] = 0; + for (const r of blames as any[]) { + if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + if (role === "damage_expert") { + const out: Record = { all: 0 }; + for (const s of Object.values(ClaimCaseStatus)) out[s] = 0; + for (const r of claims as any[]) { + if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue; + const st = r?.status as string; + if (st in out) out[st]++; + else out[st] = (out[st] ?? 0) + 1; + out.all++; + } + return out; + } + + throw new BadRequestException("Unsupported role for reports"); } - async getAllRequestsReportPerMonth(actor, client) { - const result = []; + async getAllRequestsReportCount(actor: any, client: string | undefined) { + requireActorClientKey(actor); + + if (actor.role === "damage_expert") { + const data = await this.getAllRequestsCountByRole(actor.role, client); + return new ClaimCaseStatusCountReportDtoRs(data as Record); + } + if (actor.role === "expert") { + const data = await this.getAllRequestsCountByRole(actor.role, client); + return new BlameCaseStatusCountReportDtoRs(data as Record); + } + const companyPayload = await this.getAllRequestsCountByRole( + "company", + client, + ); + return new CompanyAllRequestsCountReportDtoRs( + (companyPayload as { blame: Record }).blame, + (companyPayload as { claim: Record }).claim, + ); + } + + async getAllRequestsReportPerMonth(actor: any, client: string | undefined) { + requireActorClientKey(actor); + const result: any[] = []; const now = new Date(); - if (actor.role === "company") { - for (let i = 0; i < 5; i++) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date( - now.getFullYear(), - now.getMonth() - i + 1, - 0, - 23, - 59, - 59, - ); - const label = monthStart.toLocaleDateString("fa-IR", { - month: "long", - year: "numeric", - }); + for (let i = 0; i < 5; i++) { + const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthEnd = new Date( + now.getFullYear(), + now.getMonth() - i + 1, + 0, + 23, + 59, + 59, + ); + const label = monthStart.toLocaleDateString("fa-IR", { + month: "long", + year: "numeric", + }); - const blameData = await this.getDateFilteredRequestByStatus( - "expert", + let data: any; + if (actor.role === "company") { + data = await this.getDateFilteredRequestByStatus( + "company", client, monthStart, monthEnd, ); - const claimData = await this.getDateFilteredRequestByStatus( - "damage_expert", - client, - monthStart, - monthEnd, - ); - - result.unshift({ - stDate: monthStart, - enDate: monthEnd, - faLabel: label, - data: { - blame: blameData, - claim: claimData, - }, - }); - } - return result; - } else { - for (let i = 0; i < 5; i++) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date( - now.getFullYear(), - now.getMonth() - i + 1, - 0, - 23, - 59, - 59, - ); - const label = monthStart.toLocaleDateString("fa-IR", { - month: "long", - year: "numeric", - }); - const data = await this.getDateFilteredRequestByStatus( + } else { + data = await this.getDateFilteredRequestByStatus( actor.role, client, monthStart, monthEnd, ); - result.unshift({ - stDate: monthStart, - enDate: monthEnd, - faLabel: label, - data, - }); } - return result; + + result.unshift({ + stDate: monthStart, + enDate: monthEnd, + faLabel: label, + data, + }); } + return result; } - async getAllCheckedRequestsCount(actor, client) { - if (actor.role === "damage_expert" || actor.role === "expert") { - return this.getAllCheckedRequestsCountFn(actor.role, client); - } else { - const blame = await this.getAllCheckedRequestsCountFn("expert", client); - const claim = await this.getAllCheckedRequestsCountFn( - "damage_expert", - client, - ); - return { blame, claim }; - } + /** Same aggregates as `GET /reports/report/requests` (native status keys). */ + async getAllCheckedRequestsCount(actor: any, client: string | undefined) { + return this.getAllRequestsReportCount(actor, client); } } diff --git a/src/request-management/dto/expert-complete-car-body-form.v2.dto.ts b/src/request-management/dto/expert-complete-car-body-form.v2.dto.ts new file mode 100644 index 0000000..f49a237 --- /dev/null +++ b/src/request-management/dto/expert-complete-car-body-form.v2.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { + CarBodyFormDto, + DescriptionDto, + InitialFormDto, +} from "./create-request-management.dto"; + +/** + * V2 (IN_PERSON): single expert description/location for the scene. + */ +export class ExpertCompleteCarBodyFormV2Dto { + @ApiProperty({ + description: "First party phone number", + example: "09123456789", + }) + firstPartyPhoneNumber: string; + + @ApiProperty({ type: InitialFormDto }) + firstPartyInitialForm: InitialFormDto; + + @ApiProperty({ type: CarBodyFormDto }) + carBodyForm: CarBodyFormDto; + + @ApiProperty({ type: AddPlateDto }) + firstPartyPlate: AddPlateDto; + + @ApiProperty({ type: DescriptionDto }) + expertDescription: DescriptionDto; +} diff --git a/src/request-management/dto/expert-complete-location.v2.dto.ts b/src/request-management/dto/expert-complete-location.v2.dto.ts new file mode 100644 index 0000000..69d5a52 --- /dev/null +++ b/src/request-management/dto/expert-complete-location.v2.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { LocationDto } from "./create-request-management.dto"; + +/** + * V2 (IN_PERSON): one scene location provided by expert. + */ +export class ExpertCompleteLocationV2Dto { + @ApiProperty({ type: LocationDto }) + location: LocationDto; +} diff --git a/src/request-management/dto/expert-complete-third-party-form.v2.dto.ts b/src/request-management/dto/expert-complete-third-party-form.v2.dto.ts new file mode 100644 index 0000000..d1461ee --- /dev/null +++ b/src/request-management/dto/expert-complete-third-party-form.v2.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { DescriptionDto, InitialFormDto } from "./create-request-management.dto"; + +/** + * V2 (IN_PERSON): second party inputs without separate description/location. + */ +export class ExpertSecondPartyInfoV2Dto { + @ApiProperty({ + description: "Second party phone number", + example: "09123456789", + }) + phoneNumber: string; + + @ApiProperty({ type: InitialFormDto }) + initialForm: InitialFormDto; + + @ApiProperty({ type: AddPlateDto }) + plate: AddPlateDto; +} + +/** + * V2 (IN_PERSON): single expert description shared for the whole scene. + */ +export class ExpertCompleteThirdPartyFormV2Dto { + @ApiProperty({ + description: "First party phone number", + example: "09123456789", + }) + firstPartyPhoneNumber: string; + + @ApiProperty({ type: InitialFormDto }) + firstPartyInitialForm: InitialFormDto; + + @ApiProperty({ type: AddPlateDto }) + firstPartyPlate: AddPlateDto; + + @ApiProperty({ type: ExpertSecondPartyInfoV2Dto }) + secondParty: ExpertSecondPartyInfoV2Dto; + + @ApiProperty({ type: DescriptionDto }) + expertDescription: DescriptionDto; + + @ApiProperty({ + description: + "Phone number of the guilty party. Must match either first or second party phone number.", + example: "09123456789", + }) + guiltyPartyPhoneNumber: string; +} diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index 060915b..5b6b169 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -27,14 +27,14 @@ import { Roles } from "src/decorators/roles.decorator"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { RequestManagementService } from "./request-management.service"; import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; -import { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto"; -import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.dto"; +import { ExpertCompleteThirdPartyFormV2Dto } from "./dto/expert-complete-third-party-form.v2.dto"; +import { ExpertCompleteCarBodyFormV2Dto } from "./dto/expert-complete-car-body-form.v2.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 { ExpertCompleteLocationsDto } from "./dto/expert-complete-locations.dto"; +import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { PartyRole } from "./entities/schema/partyRole.enum"; @@ -225,11 +225,11 @@ export class ExpertInitiatedV2Controller { @ApiOperation({ summary: "[V2] Submit all blame data (IN_PERSON)", description: - "For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT locations. After this, call add-locations to submit lat/lon and move workflow to WAITING_FOR_SIGNATURES.", + "For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.", }) @ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiBody({ - 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.", + description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.", examples: { THIRD_PARTY: { summary: "THIRD_PARTY", @@ -253,7 +253,7 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 13770624, driverBirthday: "1370-01-01", }, - firstPartyDescription: { desc: "توضیح حادثه طرف اول" }, + expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" }, secondParty: { phoneNumber: "09187654321", initialForm: { @@ -273,7 +273,6 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 13700720, driverBirthday: "1370-01-01", }, - description: { desc: "توضیح حادثه طرف دوم" }, }, guiltyPartyPhoneNumber: "09123456789", }, @@ -302,7 +301,7 @@ export class ExpertInitiatedV2Controller { insurerBirthday: 1370, driverBirthday: "1370-01-01", }, - firstPartyDescription: { + expertDescription: { desc: "توضیح حادثه", accidentDate: "2025-01-15", accidentTime: "14:30", @@ -319,7 +318,7 @@ export class ExpertInitiatedV2Controller { async completeBlameDataV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, - @Body() formData: any, + @Body() formData: ExpertCompleteThirdPartyFormV2Dto | ExpertCompleteCarBodyFormV2Dto, ) { return this.requestManagementService.expertCompleteBlameDataV2( expert, @@ -330,25 +329,18 @@ export class ExpertInitiatedV2Controller { @Post("add-locations/:requestId") @ApiOperation({ - summary: "[V2] Submit location(s) for expert-initiated IN_PERSON blame", + summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame", description: - "Submit first party (and second party for THIRD_PARTY) location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.", + "Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.", }) @ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiBody({ - type: ExpertCompleteLocationsDto, + type: ExpertCompleteLocationV2Dto, examples: { - THIRD_PARTY: { - summary: "THIRD_PARTY locations", + scene: { + summary: "One scene location (all IN_PERSON types)", value: { - firstPartyLocation: { lat: 35.6892, lon: 51.389 }, - secondPartyLocation: { lat: 35.7001, lon: 51.4102 }, - }, - }, - CAR_BODY: { - summary: "CAR_BODY location", - value: { - firstPartyLocation: { lat: 35.6892, lon: 51.389 }, + location: { lat: 35.6892, lon: 51.389 }, }, }, }, @@ -357,12 +349,12 @@ export class ExpertInitiatedV2Controller { async addLocationsV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, - @Body() dto: ExpertCompleteLocationsDto, + @Body() dto: ExpertCompleteLocationV2Dto, ) { return this.requestManagementService.expertAddLocationsForBlameV2( expert, requestId, - dto, + dto as any, ); } diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 873292e..c6a27c5 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -3938,10 +3938,19 @@ export class RequestManagementService { async getAllBlameRequestsV2(user: any): Promise { try { + const userIdFilter = + user?.sub && Types.ObjectId.isValid(user.sub) + ? { "parties.person.userId": new Types.ObjectId(user.sub) } + : null; + const phoneFilter = user?.username + ? { "parties.person.phoneNumber": user.username } + : null; + + const filters = [userIdFilter, phoneFilter].filter(Boolean); + if (filters.length === 0) return []; + const requests = await this.blameRequestDbService.find( - { - "parties.person.userId": new Types.ObjectId(user.sub), - }, + filters.length === 1 ? (filters[0] as any) : ({ $or: filters } as any), { select: "requestNo type status blameStatus createdAt updatedAt parties", } @@ -3949,7 +3958,9 @@ export class RequestManagementService { const enriched = requests.map((req: any) => { const party = req.parties.find( - (p: any) => String(p.person.userId) === String(user.sub) + (p: any) => + (p?.person?.userId && String(p.person.userId) === String(user.sub)) || + (p?.person?.phoneNumber && p.person.phoneNumber === user?.username), ); const obj = req.toObject(); @@ -4105,6 +4116,9 @@ export class RequestManagementService { if (!formData.carBodyForm) { throw new BadRequestException("carBodyForm is required."); } + if (!formData?.expertDescription?.desc) { + throw new BadRequestException("expertDescription.desc is required."); + } const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, @@ -4176,12 +4190,12 @@ export class RequestManagementService { }, }, statement: { - description: formData.firstPartyDescription?.desc, - accidentDate: formData.firstPartyDescription?.accidentDate, - accidentTime: formData.firstPartyDescription?.accidentTime, - weatherCondition: formData.firstPartyDescription?.weatherCondition, - roadCondition: formData.firstPartyDescription?.roadCondition, - lightCondition: formData.firstPartyDescription?.lightCondition, + description: formData.expertDescription?.desc, + accidentDate: formData.expertDescription?.accidentDate, + accidentTime: formData.expertDescription?.accidentTime, + weatherCondition: formData.expertDescription?.weatherCondition, + roadCondition: formData.expertDescription?.roadCondition, + lightCondition: formData.expertDescription?.lightCondition, }, location: req.parties?.[0]?.location, carBodyFirstForm: { @@ -4250,6 +4264,9 @@ export class RequestManagementService { if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) { throw new BadRequestException("secondParty and guiltyPartyPhoneNumber are required."); } + if (!formData?.expertDescription?.desc) { + throw new BadRequestException("expertDescription.desc is required."); + } const firstPartyUserId = await this.getOrCreateUserByPhoneNumber( formData.firstPartyPhoneNumber, @@ -4320,7 +4337,7 @@ export class RequestManagementService { firstPartyUserId, formData.firstPartyInitialForm, formData.firstPartyPlate, - formData.firstPartyDescription?.desc || "", + formData.expertDescription?.desc || "", PartyRole.FIRST, ); const secondParty = await buildPartyFromForm( @@ -4328,7 +4345,7 @@ export class RequestManagementService { secondPartyUserId, formData.secondParty.initialForm, formData.secondParty.plate, - formData.secondParty.description?.desc || "", + formData.expertDescription?.desc || "", PartyRole.SECOND, ); @@ -4386,7 +4403,7 @@ export class RequestManagementService { async expertAddLocationsForBlameV2( expert: any, requestId: string, - dto: { firstPartyLocation: LocationDto; secondPartyLocation?: LocationDto }, + dto: { location: LocationDto }, ): Promise { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); @@ -4402,21 +4419,10 @@ export class RequestManagementService { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); if (firstIdx === -1) throw new BadRequestException("First party not found"); - if (!dto?.firstPartyLocation) { - throw new BadRequestException("firstPartyLocation is required"); - } - req.parties[firstIdx].location = dto.firstPartyLocation as any; - - if (req.type === BlameRequestType.THIRD_PARTY) { - const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); - if (secondIdx === -1) throw new BadRequestException("Second party not found"); - if (!dto?.secondPartyLocation) { - throw new BadRequestException( - "secondPartyLocation is required for THIRD_PARTY", - ); - } - req.parties[secondIdx].location = dto.secondPartyLocation as any; + if (!dto?.location) { + throw new BadRequestException("location is required"); } + req.parties[firstIdx].location = dto.location as any; const completed = Array.isArray(req.workflow?.completedSteps) ? req.workflow.completedSteps @@ -4424,12 +4430,6 @@ export class RequestManagementService { if (!completed.includes(WorkflowStep.FIRST_LOCATION as any)) { completed.push(WorkflowStep.FIRST_LOCATION as any); } - if ( - req.type === BlameRequestType.THIRD_PARTY && - !completed.includes(WorkflowStep.SECOND_LOCATION as any) - ) { - completed.push(WorkflowStep.SECOND_LOCATION as any); - } req.workflow = { ...(req.workflow || {}), @@ -4449,8 +4449,7 @@ export class RequestManagementService { actorType: expert?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", }, metadata: { - hasFirstLocation: true, - hasSecondLocation: req.type === BlameRequestType.THIRD_PARTY, + hasLocation: true, }, } as any); @@ -4533,6 +4532,12 @@ export class RequestManagementService { const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); if (firstPartyIndex === -1) throw new BadRequestException("First party not found"); const firstParty = req.parties[firstPartyIndex]; + if ( + Array.isArray(firstParty?.evidence?.voices) && + firstParty.evidence.voices.length > 0 + ) { + throw new ConflictException("Voice already uploaded for this file"); + } const voiceDocument = await this.blameVoiceDbService.create({ fileName: voiceFile.filename,