1
0
forked from Yara724/api

update some important issues

This commit is contained in:
2026-04-18 10:49:22 +03:30
parent 494e3d93ab
commit 4bdb9fd469
22 changed files with 1727 additions and 105 deletions

View File

@@ -32,6 +32,7 @@ import { MyRequestsDto } from "./dto/my-request.dto";
import { SubmitUserReplyDtoRs } from "./dto/submit-reply.dto";
import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
@@ -41,7 +42,11 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto, ClaimListItemV2Dto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
@@ -1818,6 +1823,172 @@ export class ClaimRequestManagementService {
}
}
/**
* V2: Upload repair factor file for a part marked `factorNeeded` on the expert reply (ClaimCase).
* Same behavior as v1 {@link uploadClaimFactor} but uses `evaluation.damageExpertReply` / `damageExpertReplyFinal`.
*/
async uploadClaimFactorV2(
claimRequestId: string,
partId: string,
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
) {
try {
if (!file) {
throw new BadRequestException("A factor file is required for upload.");
}
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim not found");
}
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claim,
currentUserId,
actor?.role,
);
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException("You do not have access to this claim file");
}
const replyKey = claim.evaluation?.damageExpertReplyFinal
? "damageExpertReplyFinal"
: "damageExpertReply";
const finalReply =
replyKey === "damageExpertReplyFinal"
? claim.evaluation?.damageExpertReplyFinal
: claim.evaluation?.damageExpertReply;
if (!finalReply?.parts?.length) {
throw new BadRequestException("No expert reply found in this claim.");
}
const part = finalReply.parts.find((p) => String(p.partId) === String(partId));
if (!part) {
throw new BadRequestException(
"The specified part was not found in the expert's reply.",
);
}
if (!part.factorNeeded) {
throw new BadRequestException("This part does not require a factor upload.");
}
if (part.factorLink) {
throw new ConflictException(
"A factor has already been uploaded for this part.",
);
}
if (claim.claimStatus !== ClaimStatus.NEEDS_REVISION) {
throw new BadRequestException(
"Factors can only be uploaded while the claim is in NEEDS_REVISION (expert requested factor documents). After all factors are uploaded the claim moves to UNDER_REVIEW for expert validation only.",
);
}
const partNameForRecord = part.carPartDamage ?? part.partId;
const factorRecord = await this.claimFactorsImageDbService.create({
claimId: new Types.ObjectId(claimRequestId),
partId: String(partId),
partName: partNameForRecord,
path: file.path,
fileName: file.filename,
uploadedAt: new Date(),
});
const pathPrefix = `evaluation.${replyKey}.parts`;
const updatedClaim = await this.claimCaseDbService.findOneAndUpdate(
{
_id: new Types.ObjectId(claimRequestId),
[`${pathPrefix}.partId`]: partId,
},
{
$set: {
[`${pathPrefix}.$.factorLink`]: factorRecord._id,
[`${pathPrefix}.$.factorStatus`]: FactorStatus.PENDING,
},
},
);
if (!updatedClaim) {
throw new NotFoundException(
"Could not find the specific part to update in the database.",
);
}
await this.checkAndTransitionStatusAfterFactorUploadV2(claimRequestId);
return {
message: "Factor uploaded successfully. Awaiting expert validation.",
factorId: factorRecord._id,
url: buildFileLink(file.path),
};
} catch (error) {
this.logger.error(
`Error during V2 factor upload for claim ${claimRequestId}:`,
error instanceof Error ? error.stack : String(error),
);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
"An internal server error occurred during factor upload.",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* When every `factorNeeded` part on the active expert reply has a `factorLink`, move claim toward expert validation (v1: PendingFactorValidation).
*/
private async checkAndTransitionStatusAfterFactorUploadV2(
claimRequestId: string,
): Promise<void> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim?.evaluation) {
return;
}
const finalReply =
claim.evaluation.damageExpertReplyFinal ||
claim.evaluation.damageExpertReply;
if (!finalReply?.parts) {
return;
}
const requiredFactorParts = finalReply.parts.filter((p) => p.factorNeeded === true);
if (requiredFactorParts.length === 0) {
return;
}
const allFactorsAreUploaded = requiredFactorParts.every((p) => !!p.factorLink);
if (!allFactorsAreUploaded) {
return;
}
this.logger.log(
`All required factors for claim case ${claimRequestId} are uploaded (V2). Setting claim status for validation.`,
);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { claimStatus: ClaimStatus.UNDER_REVIEW },
$push: {
history: {
type: "ALL_FACTORS_UPLOADED_PENDING_VALIDATION",
timestamp: new Date(),
metadata: { claimRequestId },
},
},
});
}
private async checkAndTransitionStatusAfterUpload(
claimId: string,
): Promise<void> {
@@ -3783,6 +3954,288 @@ export class ClaimRequestManagementService {
}
}
/**
* V2 API: Upload car walk-around video (same storage as v1: claim-video-capture + ClaimCase.media.videoCaptureId).
*/
async setVideoCaptureV2(
claimRequestId: string,
fileDetail: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<VideoCaptureV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('Only the claim owner can upload car capture video');
}
if (!fileDetail) {
throw new BadRequestException('Video file is required.');
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.CAPTURE_PART_DAMAGES}, but current step is ${claimCase.workflow?.currentStep}`,
);
}
if (claimCase.media?.videoCaptureId) {
throw new ConflictException('A video has already been uploaded for this claim.');
}
const videoDoc = await this.createVideoCapture(fileDetail, claimRequestId);
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { 'media.videoCaptureId': videoDoc._id },
$push: {
history: {
type: 'VIDEO_CAPTURE_UPLOADED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
videoId: videoDoc._id.toString(),
fileName: fileDetail.filename,
},
},
},
});
return {
claimRequestId: claimCase._id.toString(),
videoId: videoDoc._id.toString(),
message: 'Video capture uploaded successfully.',
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('setVideoCaptureV2 failed', error);
throw new InternalServerErrorException('Failed to upload video capture');
}
}
/**
* Whether an expert resend request exists (user is expected to respond).
*/
private claimCaseHasExpertResend(claimCase: any): boolean {
const r = claimCase?.evaluation?.damageExpertResend;
if (!r) return false;
if (typeof r.resendDescription === 'string' && r.resendDescription.trim() !== '') {
return true;
}
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
return false;
}
/**
* V2 API: User objection (ClaimCase + evaluation.objection payload).
*
* Preconditions: damage expert resend and/or first expert reply exists; no prior objection; no final reply yet.
* Post: claim returns to expert queue (WAITING_FOR_DAMAGE_EXPERT), objection stored, optional new parts merged into damage.selectedParts.
*/
async handleUserObjectionV2(
claimRequestId: string,
body: UserObjectionV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
) {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('Only the claim owner can submit an objection');
}
const hasExpertResend = this.claimCaseHasExpertResend(claimCase);
const hasFirstExpertReply = !!claimCase.evaluation?.damageExpertReply;
const hasFinalExpertReply = !!claimCase.evaluation?.damageExpertReplyFinal;
if (!hasExpertResend && !hasFirstExpertReply) {
throw new BadRequestException(
'You can only object after the damage expert has submitted an assessment or requested a resend.',
);
}
if (hasFinalExpertReply) {
throw new ConflictException(
'The expert has already submitted the final reply after objection; you cannot submit another objection through this endpoint.',
);
}
if (claimCase.evaluation?.objection?.submittedAt) {
throw new ConflictException('An objection has already been submitted for this claim.');
}
const partsIn = body.objectionParts ?? [];
const newPartsIn = body.newParts ?? [];
if (partsIn.length === 0 && newPartsIn.length === 0) {
throw new BadRequestException(
'Provide at least one entry in objectionParts and/or newParts.',
);
}
const objectionParts = partsIn.map((p) => ({
partId: p.partId,
reason: p.reason,
partPrice: p.partPrice,
partSalary: p.partSalary,
typeOfDamage: p.typeOfDamage != null ? String(p.typeOfDamage) : undefined,
carPartDamage: p.carPartDamage,
side: p.side,
}));
const newParts = newPartsIn.map((p) => ({
partId: p.partId ? String(p.partId) : new Types.ObjectId().toString(),
partName: p.partName,
side: p.side,
}));
const mergedSelected = [...(claimCase.damage?.selectedParts ?? [])];
for (const np of newParts) {
const key = np.partName?.trim();
if (key && !mergedSelected.includes(key)) {
mergedSelected.push(key);
}
}
const objectionPayload = {
objectionParts,
newParts,
submittedAt: new Date(),
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
'workflow.locked': false,
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
'workflow.nextStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
'evaluation.objection': objectionPayload,
'damage.selectedParts': mergedSelected,
},
$push: {
history: {
type: 'USER_OBJECTION_SUBMITTED',
actor: {
actorId: new Types.ObjectId(effectiveUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
objectionPartCount: objectionParts.length,
newPartCount: newParts.length,
},
},
},
});
return objectionPayload;
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('handleUserObjectionV2 failed', error);
throw new InternalServerErrorException('Failed to submit objection');
}
}
/**
* V2 API: User satisfaction rating for a completed claim case (same intent as v1 PUT …/request/:id/user-rating).
*/
async addUserRatingV2(
claimRequestId: string,
ratingDto: UserRatingDto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<UserClaimRating> {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException(
'Only the claim owner can submit a satisfaction rating.',
);
}
if (claimCase.status !== ClaimCaseStatus.COMPLETED) {
throw new BadRequestException(
'You can only rate a claim after it has been completed.',
);
}
if (claimCase.userRating?.createdAt) {
throw new ConflictException('A rating has already been submitted for this claim.');
}
const score = (n: number) => Number(n);
for (const [key, label] of [
[ratingDto.progressSpeed, 'progressSpeed'],
[ratingDto.registrationEase, 'registrationEase'],
[ratingDto.overallEvaluation, 'overallEvaluation'],
] as const) {
const v = score(key);
if (Number.isNaN(v) || v < 0 || v > 5) {
throw new BadRequestException(`${label} must be a number between 0 and 5.`);
}
}
const userRating: UserClaimRating = {
progressSpeed: ratingDto.progressSpeed,
registrationEase: ratingDto.registrationEase,
overallEvaluation: ratingDto.overallEvaluation,
comment: ratingDto.comment,
createdAt: new Date(),
};
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: { userRating },
$push: {
history: {
type: 'USER_RATING_SUBMITTED',
actor: {
actorId: new Types.ObjectId(effectiveUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {},
},
},
});
return userRating;
}
/**
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
*/
@@ -3929,6 +4382,15 @@ export class ClaimRequestManagementService {
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
carAngles,
damagedParts,
userRating: claim.userRating
? {
progressSpeed: claim.userRating.progressSpeed,
registrationEase: claim.userRating.registrationEase,
overallEvaluation: claim.userRating.overallEvaluation,
comment: claim.userRating.comment,
createdAt: claim.userRating.createdAt,
}
: undefined,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};

View File

@@ -5,6 +5,7 @@ import {
Param,
Post,
Patch,
Put,
Body,
UseGuards,
Get,
@@ -25,9 +26,15 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
@ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management")
@@ -101,6 +108,89 @@ export class ClaimRequestManagementV2Controller {
}
}
/**
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
*/
@Put("request/:claimRequestId/objection")
@ApiOperation({
summary: "Submit user objection (V2)",
description:
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({ type: UserObjectionV2Dto })
@ApiResponse({ status: 200, description: "Objection stored" })
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" })
async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit objection",
);
}
}
/**
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
*/
@Put("request/:claimRequestId/user-rating")
@ApiOperation({
summary: "Submit user satisfaction rating (V2)",
description:
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
"Stores scores on `ClaimCase.userRating` (05). One submission per case.",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({ type: UserRatingDto })
@ApiResponse({ status: 200, description: "Rating saved" })
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Rating already submitted" })
async addUserRatingV2(
@Param("claimRequestId") claimRequestId: string,
@Body() ratingDto: UserRatingDto,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.addUserRatingV2(
claimRequestId,
ratingDto,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to save rating",
);
}
}
@Post("create-from-blame/:blameRequestId")
@ApiParam({
name: "blameRequestId",
@@ -614,4 +704,129 @@ Returns status of each item (uploaded/captured or not).
);
}
}
/**
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
*/
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload factor file for a priced part (V2)",
description:
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
})
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
@ApiParam({ name: "partId", description: "Part id from expert reply" })
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("file", {
storage: diskStorage({
destination: "./files/claim-factors",
filename: (req, file, callback) => {
const unique = Date.now();
callback(null, `-${unique}-${file.originalname}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
async uploadFactorForPartV2(
@Param("claimRequestId") claimRequestId: string,
@Param("partId") partId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.uploadClaimFactorV2(
claimRequestId,
partId,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload factor",
);
}
}
/**
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
*/
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
},
},
})
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 50 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `claim-video-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@Patch("car-capture/:claimRequestId")
@ApiOperation({
summary: "Upload car walk-around video (V2)",
description:
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiResponse({
status: 200,
description: "Video uploaded successfully",
type: VideoCaptureV2ResponseDto,
})
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Video already uploaded" })
async captureVideoCaptureV2(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() user: any,
): Promise<VideoCaptureV2ResponseDto> {
try {
return await this.claimRequestManagementService.setVideoCaptureV2(
claimRequestId,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload car capture video",
);
}
}
}

View File

@@ -79,3 +79,26 @@ export class CapturePartV2ResponseDto {
})
message: string;
}
/**
* Response DTO for car walk-around video upload (V2)
*/
export class VideoCaptureV2ResponseDto {
@ApiProperty({
description: 'Claim case ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'ID of the stored video document (claim-video-capture)',
example: '507f1f77bcf86cd799439012',
})
videoId: string;
@ApiProperty({
description: 'Success message',
example: 'Video capture uploaded successfully.',
})
message: string;
}

View File

@@ -63,6 +63,15 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
userRating?: {
progressSpeed: number;
registrationEase: number;
overallEvaluation: number;
comment?: string;
createdAt?: Date;
};
@ApiProperty({ description: 'Created at' })
createdAt: string;

View File

@@ -0,0 +1,24 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsOptional, ValidateNested } from "class-validator";
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
/**
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
* with nested validation enabled for the v2 controller pipeline.
*/
export class UserObjectionV2Dto {
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserObjectionPartDto)
objectionParts?: UserObjectionPartDto[];
@ApiPropertyOptional({ type: [NewPartDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NewPartDto)
newParts?: NewPartDto[];
}

View File

@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
}
async findOneAndUpdate(
filter: FilterQuery<ClaimCase>,
update: any,
options?: { new?: boolean },
): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findOneAndUpdate(filter, update, {
new: options?.new !== false,
});
}
async find(
filter: FilterQuery<ClaimCase>,
options?: { lean?: boolean; select?: string },

View File

@@ -87,8 +87,9 @@ export class ClaimExpertReply {
export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply);
/** One line item the user disputes on an expert-priced part */
@Schema({ _id: false })
export class ClaimUserObjection {
export class ClaimUserObjectionPart {
@Prop({ type: String, required: true })
partId: string;
@@ -103,9 +104,47 @@ export class ClaimUserObjection {
@Prop({ type: String })
typeOfDamage?: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionSchema =
SchemaFactory.createForClass(ClaimUserObjection);
export const ClaimUserObjectionPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionPart);
@Schema({ _id: false })
export class ClaimUserObjectionNewPart {
@Prop({ type: String })
partId?: string;
@Prop({ type: String, required: true })
partName: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
/**
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}.
*/
@Schema({ _id: false })
export class ClaimUserObjectionPayload {
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
objectionParts?: ClaimUserObjectionPart[];
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
newParts?: ClaimUserObjectionNewPart[];
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
}
export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload);
@Schema({ _id: false })
export class ClaimResendRequest {
@@ -182,8 +221,8 @@ export class ClaimEvaluation {
@Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema })
objection?: ClaimUserObjection;
@Prop({ type: ClaimUserObjectionPayloadSchema })
objection?: ClaimUserObjectionPayload;
@Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload;

View File

@@ -26,6 +26,7 @@ import {
ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema";
@Schema({ _id: false })
export class RequiredDocumentRef {
@@ -75,6 +76,12 @@ export class ClaimCase {
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus;
/**
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
*/
@Prop({ default: false })
blameDocumentResendPending?: boolean;
/**
* Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
@@ -133,6 +140,12 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[];
/**
* User satisfaction rating (damaged party), after the case is completed.
*/
@Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating;
@Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId;