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; 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 { return {
...evaluation, ...evaluation,
damageExpertReply: await mapReply(evaluation.damageExpertReply), damageExpertReply: await mapReply(evaluation.damageExpertReply),
damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal), damageExpertReplyFinal: await mapReply(evaluation.damageExpertReplyFinal),
...(objection !== undefined ? { objection } : {}),
}; };
} }
@@ -6587,11 +6605,11 @@ export class ClaimRequestManagementService {
} }
// 2. Validate blame status is COMPLETED // 2. Validate blame status is COMPLETED
if (blameRequest.status !== BlameCaseStatus.COMPLETED) { // if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException( // throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`, // `Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
); // );
} // }
const parties = blameRequest.parties || []; const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY; const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
@@ -9012,6 +9030,7 @@ export class ClaimRequestManagementService {
body: UserObjectionV2Dto, body: UserObjectionV2Dto,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string }, actor?: { sub: string; role?: string },
invoiceFiles?: Express.Multer.File[],
) { ) {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); 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 = { const objectionPayload = {
objectionParts, objectionParts,
newParts, newParts,
submittedAt: new Date(), submittedAt: new Date(),
...(invoices.length > 0 && { invoices }),
}; };
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
@@ -9211,6 +9238,7 @@ export class ClaimRequestManagementService {
metadata: { metadata: {
objectionPartCount: objectionParts.length, objectionPartCount: objectionParts.length,
newPartCount: newParts.length, newPartCount: newParts.length,
invoiceCount: invoices.length,
}, },
}, },
}, },

View File

@@ -13,6 +13,7 @@ import {
Get, Get,
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
UploadedFiles,
} from "@nestjs/common"; } from "@nestjs/common";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { import {
@@ -24,7 +25,7 @@ import {
ApiBody, ApiBody,
ApiConsumes, ApiConsumes,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer"; import { diskStorage } from "multer";
import { extname } from "node:path"; import { extname } from "node:path";
import { Types } from "mongoose"; 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). * 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") @Put("request/:claimRequestId/objection")
@ApiConsumes("multipart/form-data")
@ApiOperation({ @ApiOperation({
summary: "Submit user objection (V2)", summary: "Submit user objection (V2)",
description: description:
@@ -197,14 +200,38 @@ export class ClaimRequestManagementV2Controller {
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" + "(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" + "`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" + "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({ @ApiParam({
name: "claimRequestId", name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)", description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011", 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: 200, description: "Objection stored" })
@ApiResponse({ @ApiResponse({
status: 400, status: 400,
@@ -213,17 +240,35 @@ export class ClaimRequestManagementV2Controller {
@ApiResponse({ status: 403, description: "Not the claim owner" }) @ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" }) @ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" }) @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( async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string, @Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto, @Body() body: UserObjectionV2Dto,
@CurrentUser() user: any, @CurrentUser() user: any,
@UploadedFiles() invoices?: Express.Multer.File[],
) { ) {
for (const file of invoices ?? []) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
}
try { try {
return await this.claimRequestManagementService.handleUserObjectionV2( return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user, user,
invoices,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type, Transform } from "class-transformer";
import { import {
IsArray, IsArray,
IsOptional, IsOptional,
@@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto"; import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/** /**
* V2 user objection body — same shape as v1 {@link UserObjectionDto} * V2 user objection body — submitted as multipart/form-data.
* with nested validation enabled for the v2 controller pipeline. * `objectionParts` and `newParts` are JSON-encoded strings in the form fields.
* Optional `invoices` files are uploaded as a `invoices` file array.
*/ */
export class UserObjectionV2Dto { 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) @Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => UserObjectionPartDto) @Type(() => UserObjectionPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
objectionParts?: UserObjectionPartDto[]; 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) @Validate(HasObjectionEntriesConstraint)
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => NewPartDto) @Type(() => NewPartDto)
@Transform(({ value }) => {
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
})
newParts?: NewPartDto[]; newParts?: NewPartDto[];
} }

View File

@@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart {
export const ClaimUserObjectionNewPartSchema = export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart); 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). * Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}. * Stored on {@link ClaimEvaluation.objection}.
@@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload {
@Prop({ type: Date, default: () => new Date() }) @Prop({ type: Date, default: () => new Date() })
submittedAt?: Date; submittedAt?: Date;
/** Optional supporting invoices uploaded at objection time. */
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
invoices?: ClaimObjectionInvoice[];
} }
export const ClaimUserObjectionPayloadSchema = export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload); SchemaFactory.createForClass(ClaimUserObjectionPayload);

View File

@@ -372,6 +372,21 @@ export class ExpertClaimService {
await enrichApproval("ownerInsurerApproval"); await enrichApproval("ownerInsurerApproval");
await enrichApproval("ownerPricedPartsApproval"); 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; return ev;
} }
@@ -4998,6 +5013,10 @@ export class ExpertClaimService {
if (enrichedEvaluation.priceDrop !== undefined) { if (enrichedEvaluation.priceDrop !== undefined) {
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; 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) { if (Object.keys(evaluationForApi).length === 0) {
evaluationForApi = undefined; evaluationForApi = undefined;
} }