YARA-1020

This commit is contained in:
SepehrYahyaee
2026-07-19 16:34:44 +03:30
parent 06d69aa4d0
commit 9fc4ad9931
5 changed files with 159 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> | undefined;
if (objection) {
const invoices = objection.invoices as Array<Record<string, unknown>> | 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;
}