diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 17a7864..c7b741e 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -2543,10 +2543,28 @@ export class ClaimRequestManagementService { return mapped; }; + // Resolve objection invoice paths to downloadable URLs (user-side view). + const objectionRaw = evaluation.objection; + let objection: any = objectionRaw + ? typeof objectionRaw?.toObject === "function" + ? objectionRaw.toObject() + : { ...objectionRaw } + : undefined; + if (objection && Array.isArray(objection.invoices) && objection.invoices.length > 0) { + objection = { + ...objection, + invoices: objection.invoices.map((inv: any) => ({ + ...inv, + url: inv.path ? buildFileLink(inv.path) : undefined, + })), + }; + } + return { ...evaluation, damageExpertReply: await mapReply(evaluation.damageExpertReply), damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal), + ...(objection !== undefined ? { objection } : {}), }; } @@ -6587,11 +6605,11 @@ export class ClaimRequestManagementService { } // 2. Validate blame status is COMPLETED - if (blameRequest.status !== BlameCaseStatus.COMPLETED) { - throw new BadRequestException( - `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, - ); - } + // if (blameRequest.status !== BlameCaseStatus.COMPLETED) { + // throw new BadRequestException( + // `Blame request must be COMPLETED. Current status: ${blameRequest.status}`, + // ); + // } const parties = blameRequest.parties || []; const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; @@ -9012,6 +9030,7 @@ export class ClaimRequestManagementService { body: UserObjectionV2Dto, currentUserId: string, actor?: { sub: string; role?: string }, + invoiceFiles?: Express.Multer.File[], ) { try { const claimCase = await this.claimCaseDbService.findById(claimRequestId); @@ -9176,10 +9195,18 @@ export class ClaimRequestManagementService { } } + const invoices = (invoiceFiles ?? []).map((f) => ({ + fileId: new Types.ObjectId(), + path: f.path, + fileName: f.filename, + uploadedAt: new Date(), + })); + const objectionPayload = { objectionParts, newParts, submittedAt: new Date(), + ...(invoices.length > 0 && { invoices }), }; await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { @@ -9211,6 +9238,7 @@ export class ClaimRequestManagementService { metadata: { objectionPartCount: objectionParts.length, newPartCount: newParts.length, + invoiceCount: invoices.length, }, }, }, 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 0e78a83..d6d050a 100644 --- a/src/claim-request-management/claim-request-management.v2.controller.ts +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -13,6 +13,7 @@ import { Get, UseInterceptors, UploadedFile, + UploadedFiles, } from "@nestjs/common"; import { readFile } from "node:fs/promises"; import { @@ -24,7 +25,7 @@ import { ApiBody, ApiConsumes, } from "@nestjs/swagger"; -import { FileInterceptor } from "@nestjs/platform-express"; +import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express"; import { diskStorage } from "multer"; import { extname } from "node:path"; import { Types } from "mongoose"; @@ -188,8 +189,10 @@ export class ClaimRequestManagementV2Controller { /** * V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection). + * Accepts multipart/form-data so optional supporting invoices can be attached in the same request. */ @Put("request/:claimRequestId/objection") + @ApiConsumes("multipart/form-data") @ApiOperation({ summary: "Submit user objection (V2)", description: @@ -197,14 +200,38 @@ export class ClaimRequestManagementV2Controller { "(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" + "`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" + "After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" + - "Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.", + "Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.\n\n" + + "**Invoices:** optionally attach up to 5 supporting documents (images/PDFs) as `invoices` file fields. Stored in `evaluation.objection.invoices[]` and visible to the reviewing expert.", }) @ApiParam({ name: "claimRequestId", description: "The claim case ID (MongoDB ObjectId)", example: "507f1f77bcf86cd799439011", }) - @ApiBody({ type: UserObjectionV2Dto }) + @ApiBody({ + description: + "Objection payload as multipart form fields. `objectionParts` and `newParts` are JSON-encoded strings.", + schema: { + type: "object", + properties: { + objectionParts: { + type: "string", + description: + 'JSON-encoded array of disputed priced parts. Example: `[{"partId":201,"reason":"Price too high"}]`', + }, + newParts: { + type: "string", + description: + 'JSON-encoded array of new parts to add. Example: `[{"partName":"سپر جلو","side":"front"}]`', + }, + invoices: { + type: "array", + items: { type: "string", format: "binary" }, + description: "Up to 5 supporting invoice or document files (image or PDF).", + }, + }, + }, + }) @ApiResponse({ status: 200, description: "Objection stored" }) @ApiResponse({ status: 400, @@ -213,17 +240,35 @@ export class ClaimRequestManagementV2Controller { @ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 409, description: "Objection already submitted" }) + @UseInterceptors( + FilesInterceptor("invoices", 5, { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-objection-invoices", + filename: (req, file, callback) => { + const unique = Date.now() + "-" + Math.round(Math.random() * 1e6); + const ex = extname(file.originalname); + callback(null, `objection-invoice-${unique}${ex}`); + }, + }), + }), + ) async submitUserObjectionV2( @Param("claimRequestId") claimRequestId: string, @Body() body: UserObjectionV2Dto, @CurrentUser() user: any, + @UploadedFiles() invoices?: Express.Multer.File[], ) { + for (const file of invoices ?? []) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + } try { return await this.claimRequestManagementService.handleUserObjectionV2( claimRequestId, body, user.sub, user, + invoices, ); } catch (error) { if (error instanceof HttpException) throw error; diff --git a/src/claim-request-management/dto/user-objection-v2.dto.ts b/src/claim-request-management/dto/user-objection-v2.dto.ts index bfcfe51..029517d 100644 --- a/src/claim-request-management/dto/user-objection-v2.dto.ts +++ b/src/claim-request-management/dto/user-objection-v2.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Type } from "class-transformer"; +import { Type, Transform } from "class-transformer"; import { IsArray, IsOptional, @@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto"; /** - * V2 user objection body — same shape as v1 {@link UserObjectionDto} - * with nested validation enabled for the v2 controller pipeline. + * V2 user objection body — submitted as multipart/form-data. + * `objectionParts` and `newParts` are JSON-encoded strings in the form fields. + * Optional `invoices` files are uploaded as a `invoices` file array. */ export class UserObjectionV2Dto { - @ApiPropertyOptional({ type: [UserObjectionPartDto] }) + @ApiPropertyOptional({ + type: [UserObjectionPartDto], + description: + "JSON-encoded array of disputed priced parts. Pass as a JSON string in the multipart field.", + }) @Validate(HasObjectionEntriesConstraint) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => UserObjectionPartDto) + @Transform(({ value }) => { + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; + }) objectionParts?: UserObjectionPartDto[]; - @ApiPropertyOptional({ type: [NewPartDto] }) + @ApiPropertyOptional({ + type: [NewPartDto], + description: + "JSON-encoded array of new parts to add. Pass as a JSON string in the multipart field.", + }) @Validate(HasObjectionEntriesConstraint) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => NewPartDto) + @Transform(({ value }) => { + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; + }) newParts?: NewPartDto[]; } diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts index ad1e4e5..729a992 100644 --- a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart { export const ClaimUserObjectionNewPartSchema = SchemaFactory.createForClass(ClaimUserObjectionNewPart); +// --------------------------------------------------------------------------- +// Objection invoice (supporting document uploaded alongside the objection) +// --------------------------------------------------------------------------- + +@Schema({ _id: false }) +export class ClaimObjectionInvoice { + @Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() }) + fileId: Types.ObjectId; + + @Prop({ type: String, required: true }) + path: string; + + @Prop({ type: String, required: true }) + fileName: string; + + @Prop({ type: Date, default: () => new Date() }) + uploadedAt: Date; +} +export const ClaimObjectionInvoiceSchema = + SchemaFactory.createForClass(ClaimObjectionInvoice); + /** * Full user objection payload (matches v1 DTO: objectionParts + newParts). * Stored on {@link ClaimEvaluation.objection}. @@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload { @Prop({ type: Date, default: () => new Date() }) submittedAt?: Date; + + /** Optional supporting invoices uploaded at objection time. */ + @Prop({ type: [ClaimObjectionInvoiceSchema], default: [] }) + invoices?: ClaimObjectionInvoice[]; } export const ClaimUserObjectionPayloadSchema = SchemaFactory.createForClass(ClaimUserObjectionPayload); diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 5fde6c9..8ec4c2a 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -372,6 +372,21 @@ export class ExpertClaimService { await enrichApproval("ownerInsurerApproval"); await enrichApproval("ownerPricedPartsApproval"); + // Resolve objection invoice paths to downloadable URLs. + const objection = ev.objection as Record | undefined; + if (objection) { + const invoices = objection.invoices as Array> | undefined; + if (Array.isArray(invoices) && invoices.length > 0) { + ev.objection = { + ...objection, + invoices: invoices.map((inv) => ({ + ...inv, + url: inv.path ? buildFileLink(inv.path as string) : undefined, + })), + }; + } + } + return ev; } @@ -4998,6 +5013,10 @@ export class ExpertClaimService { if (enrichedEvaluation.priceDrop !== undefined) { evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; } + // objection: include when present so experts can see the disputed parts and any attached invoices. + if (enrichedEvaluation.objection !== undefined) { + evaluationForApi.objection = enrichedEvaluation.objection; + } if (Object.keys(evaluationForApi).length === 0) { evaluationForApi = undefined; }