Compare commits

...

3 Commits

81 changed files with 11603 additions and 77 deletions

View File

@@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"@arashioz/errjson-talieh": "^2.2.5", "@arashioz/errjson-talieh": "^2.2.5",
"@fraybabak/kavenegar_nest": "^1.0.5", "@fraybabak/kavenegar_nest": "^1.0.5",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^1.8.1",
"@nestjs/axios": "^3.1.3", "@nestjs/axios": "^3.1.3",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
@@ -34,8 +34,8 @@
"@nestjs/platform-fastify": "^10.4.15", "@nestjs/platform-fastify": "^10.4.15",
"@nestjs/platform-socket.io": "^10.4.15", "@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/schedule": "^4.1.2", "@nestjs/schedule": "^4.1.2",
"@nestjs/serve-static": "^5.0.3", "@nestjs/serve-static": "^4.0.2",
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^7.4.2",
"@nestjs/websockets": "^10.4.15", "@nestjs/websockets": "^10.4.15",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -63,7 +63,7 @@
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.9", "@nestjs/cli": "^11.0.14",
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",

View File

@@ -0,0 +1,5 @@
//! NEW
export enum BlameRequestType {
THIRD_PARTY = "THIRD_PARTY",
CAR_BODY = "CAR_BODY",
}

View File

@@ -0,0 +1,8 @@
//! NEW
export enum BlameStatus {
UNKNOWN = "UNKNOWN",
AGREED = "AGREED",
DISAGREEMENT = "DISAGREEMENT"
}

View File

@@ -0,0 +1,31 @@
//! NEW
export enum WorkflowStep {
CREATED = "CREATED",
// ---------- First party ----------
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
FIRST_VIDEO = "FIRST_VIDEO",
FIRST_INITIAL_FORM = "FIRST_INITIAL_FORM",
FIRST_LOCATION = "FIRST_LOCATION",
FIRST_VOICE = "FIRST_VOICE",
FIRST_DESCRIPTION = "FIRST_DESCRIPTION",
FIRST_INVITE_SECOND = "FIRST_INVITE_SECOND",
FIRST_SIGN = "FIRST_SIGN",
FIRST_COMPLETED = "FIRST_COMPLETED",
// ---------- Second party ----------
SECOND_INITIAL_FORM = "SECOND_INITIAL_FORM",
SECOND_LOCATION = "SECOND_LOCATION",
SECOND_VOICE = "SECOND_VOICE",
SECOND_DESCRIPTION = "SECOND_DESCRIPTION",
SECOND_SIGN = "SECOND_SIGN",
SECOND_COMPLETED = "SECOND_COMPLETED",
// ---------- Resolution ----------
WAITING_FOR_GUILT_DECISION = "WAITING_FOR_GUILT_DECISION",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED"
}

View File

@@ -0,0 +1,19 @@
//! NEW
export enum CaseStatus {
OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED"
}

View File

@@ -0,0 +1,12 @@
//! NEW - Items that expert can request parties to resend
export enum ResendItemType {
// Documents
NATIONAL_CERTIFICATE = "nationalCertificate",
CAR_CERTIFICATE = "carCertificate",
DRIVING_LICENSE = "drivingLicense",
CAR_GREEN_CARD = "carGreenCard",
// Media evidence
VOICE = "voice",
VIDEO = "video",
}

View File

@@ -0,0 +1,23 @@
//! Overall claim case status (user flow + expert flow)
export enum ClaimCaseStatus {
// User flow - damage selection
CREATED = "CREATED",
SELECTING_OUTER_PARTS = "SELECTING_OUTER_PARTS",
SELECTING_OTHER_PARTS = "SELECTING_OTHER_PARTS",
// User flow - documentation
UPLOADING_REQUIRED_DOCUMENTS = "UPLOADING_REQUIRED_DOCUMENTS",
// User flow - damage capture
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
// Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
EXPERT_REVIEWING = "EXPERT_REVIEWING",
WAITING_FOR_INSURER_APPROVAL = "WAITING_FOR_INSURER_APPROVAL",
// Final states
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
REJECTED = "REJECTED",
}

View File

@@ -0,0 +1,28 @@
//! Claim workflow steps
export enum ClaimWorkflowStep {
// Initial creation
CLAIM_CREATED = "CLAIM_CREATED",
// User: Damage selection phase
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
// User: Documentation phase
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User: Damage capture phase (per part)
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
// User submission complete
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
// Expert: Damage assessment
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
// Insurer approval
INSURER_REVIEW = "INSURER_REVIEW",
// Final
CLAIM_COMPLETED = "CLAIM_COMPLETED",
}

View File

@@ -0,0 +1,13 @@
//! Claim damage determination status
export enum ClaimStatus {
// Initial state
PENDING = "PENDING",
// Expert assessment
UNDER_REVIEW = "UNDER_REVIEW",
// Final states
APPROVED = "APPROVED",
REJECTED = "REJECTED",
NEEDS_REVISION = "NEEDS_REVISION",
}

View File

@@ -1,4 +1,7 @@
export enum ClaimRequiredDocumentType { export enum ClaimRequiredDocumentType {
// Car green card
CAR_GREEN_CARD = "car_green_card",
// Damaged party documents // Damaged party documents
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back", DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front", DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",
@@ -16,3 +19,10 @@ export enum ClaimRequiredDocumentType {
GUILTY_METAL_PLATE = "guilty_metal_plate", GUILTY_METAL_PLATE = "guilty_metal_plate",
} }
export enum CarAngle {
FRONT = "front",
BACK = "back",
LEFT = "left",
RIGHT = "right",
}

View File

@@ -30,3 +30,11 @@ export enum SkillEnum {

View File

@@ -20,6 +20,7 @@ import { ReportsModule } from "./reports/reports.module";
import { RequestManagementModule } from "./request-management/request-management.module"; import { RequestManagementModule } from "./request-management/request-management.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { CronModule } from "./utils/cron/cron.module"; import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
dotenv.config(); dotenv.config();
dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
@@ -59,6 +60,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
ReportsModule, ReportsModule,
ExpertInsurerModule, ExpertInsurerModule,
LookupsModule, LookupsModule,
WorkflowStepManagementModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],

View File

@@ -26,7 +26,7 @@ import { DamageExpertDbService } from "src/users/entities/db-service/damage-expe
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { MailService } from "src/utils/mail/mail.service"; // import { MailService } from "src/utils/mail/mail.service";
import { OtpService } from "src/utils/otp/otp.service"; import { OtpService } from "src/utils/otp/otp.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
@@ -47,7 +47,7 @@ export class ActorAuthService {
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService,
private readonly insurerExpertDbService: InsurerExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService,
private readonly mailService: MailService, // private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly otpService: OtpService, private readonly otpService: OtpService,
) {} ) {}
@@ -279,17 +279,19 @@ export class ActorAuthService {
} }
const otp = this.otpService.create(); const otp = this.otpService.create();
const sendEmail = await this.mailService.sendMail(normalizedEmail, otp); // Mailer disabled not used
// const sendEmail = await this.mailService.sendMail(normalizedEmail, otp);
if (sendEmail) { // if (sendEmail) {
const hashOtp = await this.hashService.hash(otp); const hashOtp = await this.hashService.hash(otp);
await dbServiceToUpdate.findOneAndUpdate(filter, { otp: hashOtp });
await dbServiceToUpdate.findOneAndUpdate(filter, { otp: hashOtp }); return {
message: "OTP generated (mail sending disabled)",
return sendEmail; code: 200,
} else { emailSent: false,
throw new BadGatewayException("mailServer in unavailable"); };
} // } else {
// throw new BadGatewayException("mailServer in unavailable");
// }
} }
async forgetPasswordVerify(email, otp, newPassword) { async forgetPasswordVerify(email, otp, newPassword) {

View File

@@ -10,13 +10,13 @@ import { UserAuthService } from "src/auth/auth-services/user.auth.service";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
import { MailModule } from "src/utils/mail/mail.module"; // import { MailModule } from "src/utils/mail/mail.module";
import { OtpModule } from "src/utils/otp/otp.module"; import { OtpModule } from "src/utils/otp/otp.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module"; import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
@Module({ @Module({
imports: [ imports: [
MailModule, // MailModule, // Mailer disabled not used
UsersModule, UsersModule,
ClientModule, ClientModule,
HashModule, HashModule,

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiSchema } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsMobilePhone, IsString, Length } from "class-validator"; import { IsMobilePhone, IsString, Length } from "class-validator";
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema"; import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
@@ -28,10 +28,6 @@ export class ProfileActor {
} }
} }
@ApiSchema({
name: "Edit Actor Profile",
description: "Body of the request to edit the actor's profile",
})
export class ActorEditUserProfileDto { export class ActorEditUserProfileDto {
@ApiProperty({ @ApiProperty({
type: "string", type: "string",

View File

@@ -31,6 +31,7 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum"; import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto";
import { UserCommentDto } from "./dto/user-comment.dto"; import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto"; import { UserObjectionDto } from "./dto/user-objection.dto";
@@ -167,20 +168,7 @@ export class ClaimRequestManagementController {
@ApiParam({ name: "claimRequestID" }) @ApiParam({ name: "claimRequestID" })
@ApiQuery({ @ApiQuery({
name: "documentType", name: "documentType",
enum: [ enum: ClaimRequiredDocumentType,
"damaged_driving_license_back",
"damaged_driving_license_front",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
],
description: "Type of required document to upload", description: "Type of required document to upload",
}) })
@Patch("upload-required-document/:claimRequestID") @Patch("upload-required-document/:claimRequestID")

View File

@@ -5,9 +5,12 @@ import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ClaimRequestManagementController } from "./claim-request-management.controller"; import { ClaimRequestManagementController } from "./claim-request-management.controller";
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service"; import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -38,12 +41,14 @@ import {
VideoCaptureModel, VideoCaptureModel,
VideoCaptureSchema, VideoCaptureSchema,
} from "./entites/schema/video-capture.schema"; } from "./entites/schema/video-capture.schema";
import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { ClientModule } from "src/client/client.module"; import { ClientModule } from "src/client/client.module";
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
@Module({ @Module({
imports: [ imports: [
PublicIdModule,
UsersModule, UsersModule,
RequestManagementModule, RequestManagementModule,
AiModule, AiModule,
@@ -51,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt";
ClientModule, ClientModule,
JwtModule.register({}), JwtModule.register({}),
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: ClaimCase.name, schema: ClaimCaseSchema },
{ {
name: ClaimRequestManagementModel.name, name: ClaimRequestManagementModel.name,
schema: ClaimRequestManagementSchema, schema: ClaimRequestManagementSchema,
@@ -69,7 +75,9 @@ import { JwtModule } from "@nestjs/jwt";
providers: [ providers: [
ClaimRequestManagementService, ClaimRequestManagementService,
ClaimRequestManagementDbService, ClaimRequestManagementDbService,
ClaimCaseDbService,
CarGreenCardDbService, CarGreenCardDbService,
ClaimCaseDbService,
DamageImageDbService, DamageImageDbService,
ClaimSignDbService, ClaimSignDbService,
ClaimFactorsImageDbService, ClaimFactorsImageDbService,
@@ -77,10 +85,11 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,
ClaimAccessGuard, ClaimAccessGuard,
], ],
controllers: [ClaimRequestManagementController], controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
exports: [ exports: [
ClaimRequestManagementService, ClaimRequestManagementService,
ClaimRequestManagementDbService, ClaimRequestManagementDbService,
ClaimCaseDbService,
DamageImageDbService, DamageImageDbService,
VideoCaptureDbService, VideoCaptureDbService,
ClaimRequiredDocumentDbService, ClaimRequiredDocumentDbService,

View File

@@ -7,6 +7,8 @@ import {
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
ConflictException,
InternalServerErrorException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@@ -32,6 +34,21 @@ import { UserCommentDto } from "./dto/user-comment.dto";
import { UserObjectionDto } from "./dto/user-objection.dto"; import { UserObjectionDto } from "./dto/user-objection.dto";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CreateClaimFromBlameResponseDto } from "./dto/create-claim-v2.dto";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
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 { 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";
import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -41,12 +58,11 @@ import {
ClaimRequestManagementModel, ClaimRequestManagementModel,
UserClaimRating, UserClaimRating,
} from "./entites/schema/claim-request-management.schema"; } from "./entites/schema/claim-request-management.schema";
import { PublicIdService } from "src/utils/public-id/public-id.service";
import { ImageRequiredModel } from "./entites/schema/image-required.schema"; import { ImageRequiredModel } from "./entites/schema/image-required.schema";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; 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 { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { UserRatingDto } from "./dto/user-rating.dto"; import { UserRatingDto } from "./dto/user-rating.dto";
@Injectable() @Injectable()
@@ -75,6 +91,8 @@ export class ClaimRequestManagementService {
constructor( constructor(
private readonly blameDbService: RequestManagementDbService, private readonly blameDbService: RequestManagementDbService,
private readonly claimDbService: ClaimRequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly carGreenCardDbService: CarGreenCardDbService, private readonly carGreenCardDbService: CarGreenCardDbService,
private readonly damageImageDbService: DamageImageDbService, private readonly damageImageDbService: DamageImageDbService,
private readonly userDbService: UserDbService, private readonly userDbService: UserDbService,
@@ -86,6 +104,7 @@ export class ClaimRequestManagementService {
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly branchDbService: BranchDbService, private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly publicIdService: PublicIdService,
) {} ) {}
private delay(ms: number): Promise<void> { private delay(ms: number): Promise<void> {
@@ -157,8 +176,22 @@ export class ClaimRequestManagementService {
if (!blameRequest) { if (!blameRequest) {
throw new NotFoundException("Source blame request not found."); throw new NotFoundException("Source blame request not found.");
} }
// Ensure the blame request has a shared human-friendly id.
// For legacy records, generate it lazily exactly once.
let publicId = (blameRequest as any).publicId as string | undefined;
if (!publicId) {
publicId = await this.publicIdService.generateRequestPublicId();
await this.blameDbService.findAndUpdate(
{ _id: new Types.ObjectId(requestId) },
{ $set: { publicId } },
);
}
const existingClaimCount = await this.claimDbService.countByFilter({ const existingClaimCount = await this.claimDbService.countByFilter({
"blameFile._id": new Types.ObjectId(requestId), "blameFile._id": new Types.ObjectId(requestId),
blameRequestId: new Types.ObjectId(requestId),
publicId,
}); });
if (existingClaimCount > 0) { if (existingClaimCount > 0) {
throw new ForbiddenException( throw new ForbiddenException(
@@ -2678,4 +2711,911 @@ export class ClaimRequestManagementService {
} }
} }
} }
/**
* V2: Create claim request from completed blame case
* Only damaged party can create claim after both parties accept expert decision
*/
async createClaimFromBlameV2(
blameRequestId: string,
currentUserId: string,
): Promise<CreateClaimFromBlameResponseDto> {
try {
// 1. Fetch blame request from new blameCases collection
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
if (!blameRequest) {
throw new NotFoundException("Blame request not found");
}
// 2. Validate blame status is COMPLETED
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
);
}
// 3. Validate expert decision exists
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
// 4. Validate both parties have signed and accepted
const parties = blameRequest.parties || [];
if (parties.length < 2) {
throw new BadRequestException(
"Both parties must be present in the blame request",
);
}
const allPartiesSigned = parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
// 5. Validate current user is the DAMAGED party (NOT the guilty party)
const currentUserParty = parties.find(
(p) => String(p.person?.userId) === currentUserId,
);
if (!currentUserParty) {
throw new ForbiddenException(
"You are not a party in this blame request",
);
}
const currentUserIsGuilty = parties.some(
(p) =>
String(p.person?.userId) === currentUserId &&
String((p as any)._id || p.person?.userId) === guiltyPartyId,
);
// Better check: compare guiltyPartyId with person.userId of each party
const guiltyParty = parties.find((p) => {
// guiltyPartyId could be userId or party identifier
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
// 6. Validate no existing claim for this blame
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
});
if (existingClaim) {
throw new ConflictException(
"A claim request for this blame case already exists",
);
}
// 7. Generate claim number
const claimNo = await this.generateUniqueClaimNumber();
// 8. Create claim case
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING,
workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
owner: {
userId: new Types.ObjectId(currentUserId),
userRole: currentUserParty.role as any,
},
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: currentUserParty.person?.fullName || "User",
actorType: "user",
},
timestamp: new Date(),
metadata: {
blameRequestId,
blamePublicId: blameRequest.publicId,
},
},
],
} as any);
this.logger.log(
`Claim created: ${newClaim._id} from blame: ${blameRequestId}`,
);
return {
claimRequestId: String(newClaim._id),
publicId: blameRequest.publicId,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
message: "Claim request created successfully",
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"createClaimFromBlameV2 failed",
blameRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to create claim request",
);
}
}
/**
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
*
* @param claimRequestId - The claim case ID
* @param body - Selected outer parts
* @param currentUserId - The current user's ID
* @returns Response with updated claim information
*
* Validations:
* - Claim case must exist
* - User must be the claim owner (damaged party)
* - Current workflow step must be CLAIM_CREATED (step 1)
* - Selected parts array must not be empty
* - Parts must not have been selected previously
*/
async selectOuterPartsV2(
claimRequestId: string,
body: SelectOuterPartsV2Dto,
currentUserId: string,
): Promise<SelectOuterPartsV2ResponseDto> {
try {
// 1. Validate claim exists
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(
`Claim case with ID ${claimRequestId} not found`
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can select damaged parts'
);
}
// 3. Validate workflow step is correct (should be at SELECT_OUTER_PARTS)
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OUTER_PARTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OUTER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// 4. Validate outer parts haven't been selected yet
if (claimCase.damage?.selectedParts && claimCase.damage.selectedParts.length > 0) {
throw new ConflictException(
'Outer parts have already been selected for this claim'
);
}
// 5. Convert selected parts to storage format (simple array of strings)
const selectedParts = body.selectedParts;
// 6. Update claim case with selected parts and move to next step
const updatedClaim = 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(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.SELECT_OUTER_PARTS,
selectedParts: selectedParts,
partsCount: selectedParts.length,
description: `User selected ${selectedParts.length} damaged outer parts`,
},
},
},
},
);
if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case');
}
this.logger.log(
`Outer parts selected for claim ${claimRequestId}: ${selectedParts.length} parts`,
);
// 7. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
selectedParts: selectedParts,
currentStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
nextStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message: 'Outer parts selected successfully. Please proceed to select other parts and provide bank information.',
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
'selectOuterPartsV2 failed',
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: 'Failed to select outer parts',
);
}
}
/**
* V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow)
*
* @param claimRequestId - The claim case ID
* @param body - Other parts, Sheba number, and national code
* @param currentUserId - The current user's ID
* @returns Response with updated claim information
*
* Validations:
* - Claim case must exist
* - User must be the claim owner (damaged party)
* - Current workflow step must be SELECT_OTHER_PARTS (step 3)
* - Other parts and bank info must not have been submitted previously
*/
async selectOtherPartsV2(
claimRequestId: string,
body: SelectOtherPartsV2Dto,
currentUserId: string,
): Promise<SelectOtherPartsV2ResponseDto> {
try {
// 1. Validate claim exists
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(
`Claim case with ID ${claimRequestId} not found`
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can submit other parts and bank information'
);
}
// 3. Validate workflow step is correct (should be at SELECT_OTHER_PARTS)
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.SELECT_OTHER_PARTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.SELECT_OTHER_PARTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// 4. Validate other parts and bank info haven't been submitted yet
if (claimCase.money?.sheba || claimCase.money?.nationalCodeOfInsurer) {
throw new ConflictException(
'Bank information has already been submitted for this claim'
);
}
// 5. Prepare data
const otherParts = body.otherParts || [];
const shebaNumber = body.shebaNumber;
const nationalCode = body.nationalCodeOfOwner;
// 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`,
},
},
},
},
);
if (!updatedClaim) {
throw new InternalServerErrorException('Failed to update claim case');
}
this.logger.log(
`Other parts and bank info submitted for claim ${claimRequestId}: ${otherParts.length} parts`,
);
// 7. Mask sensitive data for response
const maskedSheba = shebaNumber.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3');
const maskedNationalCode = nationalCode.replace(/^(.{2})(.*)(.{2})$/, '$1******$3');
// 8. Return response
return {
claimRequestId: updatedClaim._id.toString(),
publicId: updatedClaim.publicId,
otherParts: otherParts,
shebaNumber: maskedSheba,
nationalCodeOfOwner: maskedNationalCode,
currentStep: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
nextStep: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
'selectOtherPartsV2 failed',
claimRequestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error
? error.message
: 'Failed to select other parts',
);
}
}
/**
* V2 API: Get capture requirements for claim (documents, angles, parts)
*/
async getCaptureRequirementsV2(
claimRequestId: string,
currentUserId: string,
): Promise<GetCaptureRequirementsV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can view capture requirements');
}
// Helper to get part labels
const getPartLabel = (key: string): { fa: string; en: string } => {
const labels = {
hood: { fa: 'کاپوت', en: 'Hood' },
trunk: { fa: 'صندوق عقب', en: 'Trunk' },
roof: { fa: 'سقف', en: 'Roof' },
front_right_door: { fa: 'درب جلو راست', en: 'Front Right Door' },
front_left_door: { fa: 'درب جلو چپ', en: 'Front Left Door' },
rear_right_door: { fa: 'درب عقب راست', en: 'Rear Right Door' },
rear_left_door: { fa: 'درب عقب چپ', en: 'Rear Left Door' },
front_bumper: { fa: 'سپر جلو', en: 'Front Bumper' },
rear_bumper: { fa: 'سپر عقب', en: 'Rear Bumper' },
front_right_fender: { fa: 'گلگیر جلو راست', en: 'Front Right Fender' },
front_left_fender: { fa: 'گلگیر جلو چپ', en: 'Front Left Fender' },
rear_right_fender: { fa: 'گلگیر عقب راست', en: 'Rear Right Fender' },
rear_left_fender: { fa: 'گلگیر عقب چپ', en: 'Rear Left Fender' },
};
return labels[key] || { fa: key, en: key };
};
// Required documents
const requiredDocsDefinition = [
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' },
{ key: 'damaged_driving_license_front', label_fa: 'گواهینامه طرف آسیب‌دیده - جلو', label_en: 'Damaged Party License - Front', category: 'damaged_party' },
{ key: 'damaged_driving_license_back', label_fa: 'گواهینامه طرف آسیب‌دیده - پشت', label_en: 'Damaged Party License - Back', category: 'damaged_party' },
{ key: 'damaged_chassis_number', label_fa: 'شماره شاسی', label_en: 'Chassis Number', category: 'damaged_party' },
{ key: 'damaged_engine_photo', label_fa: 'عکس موتور', label_en: 'Engine Photo', category: 'damaged_party' },
{ key: 'damaged_car_card_front', label_fa: 'کارت خودرو آسیب‌دیده - جلو', label_en: 'Damaged Car Card - Front', category: 'damaged_party' },
{ key: 'damaged_car_card_back', label_fa: 'کارت خودرو آسیب‌دیده - پشت', label_en: 'Damaged Car Card - Back', category: 'damaged_party' },
{ key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیب‌دیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
];
const requiredDocuments = requiredDocsDefinition.map(doc => {
const docData = (claimCase.requiredDocuments as any)?.get?.(doc.key);
return {
key: doc.key,
label_fa: doc.label_fa,
label_en: doc.label_en,
category: doc.category,
uploaded: docData?.uploaded || false,
};
});
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Car angles
const carAngles = [
{ key: 'front', label_fa: 'جلو', label_en: 'Front' },
{ key: 'back', label_fa: 'عقب', label_en: 'Back' },
{ key: 'left', label_fa: 'چپ', label_en: 'Left' },
{ key: 'right', label_fa: 'راست', label_en: 'Right' },
].map(angle => ({
key: angle.key,
label_fa: angle.label_fa,
label_en: angle.label_en,
captured: !!hasCapture(claimCase.media?.carAngles as any, angle.key),
}));
// Damaged parts from selected parts
const selectedParts = claimCase.damage?.selectedParts || [];
const damagedParts = selectedParts.map(partKey => {
const label = getPartLabel(partKey);
return {
key: partKey,
label_fa: label.fa,
label_en: label.en,
captured: !!hasCapture(claimCase.media?.damagedParts as any, partKey),
};
});
// Calculate progress
const documentsUploaded = requiredDocuments.filter(d => d.uploaded).length;
const anglesCaptured = carAngles.filter(a => a.captured).length;
const partsCaptured = damagedParts.filter(p => p.captured).length;
const totalRemaining =
(requiredDocuments.length - documentsUploaded) +
(carAngles.length - anglesCaptured) +
(damagedParts.length - partsCaptured);
return {
claimRequestId: claimCase._id.toString(),
publicId: claimCase.publicId,
currentStep: claimCase.workflow?.currentStep || '',
requiredDocuments,
carAngles,
damagedParts,
totalRemaining,
progress: {
documentsUploaded,
documentsTotal: requiredDocuments.length,
anglesCaptured,
anglesTotal: carAngles.length,
partsCaptured,
partsTotal: damagedParts.length,
},
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getCaptureRequirementsV2 failed', error);
throw new InternalServerErrorException('Failed to get capture requirements');
}
}
/**
* V2 API: Upload required document
*/
async uploadRequiredDocumentV2(
claimRequestId: string,
body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File,
currentUserId: string,
): Promise<UploadRequiredDocumentV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can upload documents');
}
if (claimCase.workflow?.currentStep !== ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS) {
throw new BadRequestException(
`Invalid workflow step. Expected ${ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS}, but current step is ${claimCase.workflow?.currentStep}`
);
}
// Check if document already uploaded
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
if (existingDoc?.uploaded) {
throw new ConflictException(`Document ${body.documentKey} has already been uploaded`);
}
const fileUrl = buildFileLink(file.path);
// Create document reference in claim-required-documents collection
const docRef = await this.claimRequiredDocumentDbService.create({
path: file.path,
fileName: file.filename,
claimId: new Types.ObjectId(claimRequestId),
documentType: body.documentKey,
uploadedAt: new Date(),
});
// Update claim case
const updateData: any = {
[`requiredDocuments.${body.documentKey}`]: {
fileId: docRef._id,
filePath: file.path,
fileName: file.filename,
uploaded: true,
uploadedAt: new Date(),
},
$push: {
history: {
type: 'DOCUMENT_UPLOADED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
documentKey: body.documentKey,
fileName: file.filename,
fileId: docRef._id.toString(),
},
},
},
};
// Check if all documents are uploaded
const totalDocs = 13;
const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
const allDocumentsUploaded = uploadedCount >= totalDocs;
// If all documents uploaded, move to next step
if (allDocumentsUploaded) {
updateData['status'] = ClaimCaseStatus.CAPTURING_PART_DAMAGES;
updateData['workflow.currentStep'] = ClaimWorkflowStep.CAPTURE_PART_DAMAGES;
updateData['workflow.nextStep'] = ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
updateData.$push.history = [
updateData.$push.history,
{
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
description: 'All required documents uploaded',
},
},
];
updateData.$push['workflow.completedSteps'] = ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
const remaining = totalDocs - uploadedCount;
const message = allDocumentsUploaded
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
: `Document uploaded successfully. ${remaining} documents remaining.`;
return {
claimRequestId: claimCase._id.toString(),
documentKey: body.documentKey,
fileUrl,
allDocumentsUploaded,
currentStep: allDocumentsUploaded ? ClaimWorkflowStep.CAPTURE_PART_DAMAGES : ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
message,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('uploadRequiredDocumentV2 failed', error);
throw new InternalServerErrorException('Failed to upload document');
}
}
/**
* V2 API: Capture car angle or damaged part
*/
async capturePartV2(
claimRequestId: string,
body: CapturePartV2Dto,
file: Express.Multer.File,
currentUserId: string,
): Promise<CapturePartV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
if (!claimCase) {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('Only the claim owner can capture parts');
}
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}`
);
}
const fileUrl = buildFileLink(file.path);
const captureData = {
path: file.path,
fileName: file.filename,
url: fileUrl,
capturedAt: new Date(),
};
const updateData: any = {
$push: {
history: {
type: body.captureType === 'angle' ? 'ANGLE_CAPTURED' : 'PART_CAPTURED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
captureType: body.captureType,
captureKey: body.captureKey,
fileName: file.filename,
},
},
},
};
if (body.captureType === 'angle') {
updateData[`media.carAngles.${body.captureKey}`] = captureData;
} else {
updateData[`media.damagedParts.${body.captureKey}`] = captureData;
}
const updatedClaim = await this.claimCaseDbService.findByIdAndUpdate(
claimRequestId,
updateData,
);
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Use same logic as getCaptureRequirementsV2 to determine completion
const anglesKeys = ['front', 'back', 'left', 'right'];
const carAnglesData = updatedClaim?.media?.carAngles as any;
const damagedPartsData = updatedClaim?.media?.damagedParts as any;
const selectedParts = updatedClaim?.damage?.selectedParts || [];
const anglesCaptured = anglesKeys.filter(k => hasCapture(carAnglesData, k)).length;
const partsCaptured = selectedParts.filter(p => hasCapture(damagedPartsData, p)).length;
const allCapturesComplete =
anglesCaptured >= 4 && partsCaptured >= selectedParts.length;
if (allCapturesComplete) {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
'workflow.currentStep': ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
'workflow.nextStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
history: {
type: 'STEP_COMPLETED',
actor: {
actorId: new Types.ObjectId(currentUserId),
actorName: claimCase.owner?.fullName || 'User',
actorType: 'user',
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description: 'User submission complete. Claim ready for damage expert review.',
},
},
},
});
}
const remaining = (4 - anglesCaptured) + (selectedParts.length - partsCaptured);
const message = allCapturesComplete
? 'All captures complete. Your claim is now ready for damage expert review.'
: `${body.captureType === 'angle' ? 'Angle' : 'Part'} captured successfully. ${remaining} captures remaining.`;
return {
claimRequestId: claimCase._id.toString(),
captureType: body.captureType,
captureKey: body.captureKey,
fileUrl,
allCapturesComplete,
currentStep: allCapturesComplete
? ClaimWorkflowStep.USER_SUBMISSION_COMPLETE
: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
message,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('capturePartV2 failed', error);
throw new InternalServerErrorException('Failed to capture part');
}
}
/**
* V2 API: Get list of claims for current user
*/
async getMyClaimsV2(currentUserId: string): Promise<GetMyClaimsV2ResponseDto> {
try {
const claims = await this.claimCaseDbService.find(
{ 'owner.userId': new Types.ObjectId(currentUserId) },
{ lean: true },
);
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
requestNo: c.requestNo,
status: c.status,
claimStatus: c.claimStatus || 'PENDING',
currentStep: c.workflow?.currentStep || '',
createdAt: c.createdAt,
blameRequestId: c.blameRequestId?.toString(),
})) as ClaimListItemV2Dto[];
return { list, total: list.length };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getMyClaimsV2 failed', error);
throw new InternalServerErrorException('Failed to get claims list');
}
}
/**
* V2 API: Get claim details by ID (owner only)
*/
async getClaimDetailsV2(
claimRequestId: string,
currentUserId: string,
): Promise<ClaimDetailsV2ResponseDto> {
try {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (!claim.owner?.userId || claim.owner.userId.toString() !== currentUserId) {
throw new ForbiddenException('You do not have access to this claim');
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, { uploaded: boolean; fileId?: string }> = {};
if (requiredDocs) {
const keys = requiredDocs instanceof Map ? Array.from(requiredDocs.keys()) : Object.keys(requiredDocs);
for (const k of keys) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
};
}
}
const carAnglesData = claim.media?.carAngles as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
for (const p of claim.damage?.selectedParts || []) {
const cap = hasCapture(damagedPartsData, p);
damagedParts[p] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
const maskSheba = (s?: string) =>
s ? s.replace(/^(.{4})(.*)(.{4})$/, 'IR$1************$3') : undefined;
const maskNationalCode = (s?: string) =>
s ? s.replace(/^(.{2})(.*)(.{2})$/, '$1******$3') : undefined;
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
requestNo: claim.requestNo,
status: claim.status,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: claim.vehicle,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
money: claim.money
? {
sheba: maskSheba(claim.money.sheba),
nationalCodeOfOwner: maskNationalCode(claim.money.nationalCodeOfInsurer),
}
: undefined,
requiredDocuments: Object.keys(requiredDocumentsStatus).length > 0 ? requiredDocumentsStatus : undefined,
carAngles,
damagedParts,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error('getClaimDetailsV2 failed', error);
throw new InternalServerErrorException('Failed to get claim details');
}
}
private async generateUniqueClaimNumber(): Promise<string> {
const prefix = "CL";
const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits
return `${prefix}${randomPart}`;
}
} }

View File

@@ -0,0 +1,609 @@
import {
Controller,
HttpException,
InternalServerErrorException,
Param,
Post,
Patch,
Body,
UseGuards,
Get,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { extname } from "path";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
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 { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
@ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management")
@ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER)
export class ClaimRequestManagementV2Controller {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Get("requests")
@ApiOperation({
summary: "Get My Claims (V2)",
description: "Get list of all claim requests for the current user.",
})
@ApiResponse({
status: 200,
description: "List of user claims",
type: GetMyClaimsV2ResponseDto,
})
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get claims list",
);
}
}
@Get("request/:claimRequestId")
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiOperation({
summary: "Get Claim Details (V2)",
description: "Get full details of a claim request. Only the claim owner can access.",
})
@ApiResponse({
status: 200,
description: "Claim details",
type: ClaimDetailsV2ResponseDto,
})
@ApiResponse({
status: 403,
description: "User is not the claim owner",
})
@ApiResponse({
status: 404,
description: "Claim not found",
})
async getClaimDetails(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any,
): Promise<ClaimDetailsV2ResponseDto> {
try {
return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get claim details",
);
}
}
@Post("create-from-blame/:blameRequestId")
@ApiParam({
name: "blameRequestId",
description: "ID of the completed blame case to create claim from",
})
async createClaimFromBlame(
@Param("blameRequestId") blameRequestId: string,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.createClaimFromBlameV2(
blameRequestId,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to create claim request",
);
}
}
/**
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
*/
@Patch("select-outer-parts/:claimRequestId")
@ApiOperation({
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
description: `
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
**Purpose:** User selects which outer car parts (body parts) were damaged in the accident.
**Validations:**
- Claim must exist
- User must be the claim owner (damaged party from blame case)
- Current workflow step must be CLAIM_CREATED
- Parts array must contain at least 1 part
- No duplicate parts allowed
- Parts cannot be re-selected once submitted
**Valid Outer Parts:**
- hood, trunk, roof
- front_right_door, front_left_door, rear_right_door, rear_left_door
- front_bumper, rear_bumper
- front_right_fender, front_left_fender, rear_right_fender, rear_left_fender
**After Success:**
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
- User can proceed to select non-body parts and provide bank info
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
type: SelectOuterPartsV2Dto,
description: "Array of selected damaged outer parts",
examples: {
example1: {
summary: "Minor front damage",
value: {
selectedParts: ["hood", "front_bumper", "front_right_fender"],
},
},
example2: {
summary: "Side impact damage",
value: {
selectedParts: [
"front_left_door",
"rear_left_door",
"front_left_fender",
"rear_left_fender",
],
},
},
example3: {
summary: "Rear-end collision",
value: {
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
},
},
example4: {
summary: "Multiple damage areas",
value: {
selectedParts: [
"hood",
"front_bumper",
"front_right_door",
"rear_bumper",
"trunk",
],
},
},
},
})
@ApiResponse({
status: 200,
description: "Outer parts selected successfully",
type: SelectOuterPartsV2ResponseDto,
})
@ApiResponse({
status: 400,
description: "Invalid workflow step or validation failed",
})
@ApiResponse({
status: 403,
description: "User is not the claim owner",
})
@ApiResponse({
status: 404,
description: "Claim case not found",
})
@ApiResponse({
status: 409,
description: "Outer parts already selected",
})
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() user: any,
): Promise<SelectOuterPartsV2ResponseDto> {
try {
return await this.claimRequestManagementService.selectOuterPartsV2(
claimRequestId,
body,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to select outer parts",
);
}
}
/**
* V2 API: Select other damaged parts and provide bank information (Step 3 of claim workflow)
*/
@Patch("select-other-parts/:claimRequestId")
@ApiOperation({
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
description: `
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
**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
- National code must be exactly 10 digits
**Valid Other Parts (Optional):**
- engine, suspension, brake_system, electrical
- radiator, transmission, exhaust
- headlight, taillight, mirror, glass
**After Success:**
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4)
- User can proceed to upload required documents
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@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",
},
},
},
})
@ApiResponse({
status: 200,
description: "Other parts and bank information saved successfully",
type: SelectOtherPartsV2ResponseDto,
})
@ApiResponse({
status: 400,
description: "Invalid workflow step or validation failed",
})
@ApiResponse({
status: 403,
description: "User is not the claim owner",
})
@ApiResponse({
status: 404,
description: "Claim case not found",
})
@ApiResponse({
status: 409,
description: "Bank information already submitted",
})
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() user: any,
): Promise<SelectOtherPartsV2ResponseDto> {
try {
return await this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to select other parts",
);
}
}
/**
* V2 API: Get capture requirements (documents, angles, parts)
*/
@Get("capture-requirements/:claimRequestId")
@ApiOperation({
summary: "Get Capture Requirements (V2)",
description: `
**Get list of what needs to be captured:**
- Required documents (13 items)
- Car angles (4 items: front, back, left, right)
- Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not).
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiResponse({
status: 200,
description: "Capture requirements retrieved successfully",
type: GetCaptureRequirementsV2ResponseDto,
})
@ApiResponse({
status: 403,
description: "User is not the claim owner",
})
@ApiResponse({
status: 404,
description: "Claim case not found",
})
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
try {
return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to get capture requirements",
);
}
}
/**
* V2 API: Upload required document
*/
@Post("upload-document/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload Required Document (V2 - Step 4)",
description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
**Upload one of the 13 required documents:**
- car_green_card
- damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back
- guilty_car_card_front/back, guilty_metal_plate
**When all 13 documents are uploaded:**
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["documentKey", "file"],
properties: {
documentKey: {
type: "string",
enum: [
"car_green_card",
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
"damaged_engine_photo",
"damaged_car_card_front",
"damaged_car_card_back",
"damaged_metal_plate",
"guilty_driving_license_front",
"guilty_driving_license_back",
"guilty_car_card_front",
"guilty_car_card_back",
"guilty_metal_plate",
],
example: "car_green_card",
},
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiResponse({
status: 200,
description: "Document uploaded successfully",
type: UploadRequiredDocumentV2ResponseDto,
})
@ApiResponse({
status: 400,
description: "Invalid workflow step",
})
@ApiResponse({
status: 409,
description: "Document already uploaded",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
try {
return await this.claimRequestManagementService.uploadRequiredDocumentV2(
claimRequestId,
body,
file,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to upload document",
);
}
}
/**
* V2 API: Capture car angle or damaged part
*/
@Post("capture-part/:claimRequestId")
@UseInterceptors(
FileInterceptor("file", {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)",
description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim)
**Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.**
`,
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
'For angle: front/back/left/right. For part: hood/front_bumper/etc.',
},
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiResponse({
status: 200,
description: "Capture saved successfully",
type: CapturePartV2ResponseDto,
})
@ApiResponse({
status: 400,
description: "Invalid workflow step",
})
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: any,
): Promise<CapturePartV2ResponseDto> {
try {
return await this.claimRequestManagementService.capturePartV2(
claimRequestId,
body,
file,
user.sub,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to capture part",
);
}
}
}

View File

@@ -0,0 +1,81 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
/**
* V2 DTO for capturing car angle or damaged part
*/
export class CapturePartV2Dto {
@ApiProperty({
description: 'Type of capture: angle or part',
example: 'angle',
enum: ['angle', 'part'],
})
@IsNotEmpty({ message: 'Capture type is required' })
@IsEnum(['angle', 'part'], {
message: 'Capture type must be either "angle" or "part"',
})
captureType: 'angle' | 'part';
@ApiProperty({
description: 'Key of the angle or part being captured',
example: 'front',
})
@IsNotEmpty({ message: 'Capture key is required' })
@IsString({ message: 'Capture key must be a string' })
captureKey: string;
@ApiProperty({
type: 'string',
format: 'binary',
description: 'Image file (JPG, PNG)',
})
file: Express.Multer.File;
}
/**
* Response DTO for capture part V2
*/
export class CapturePartV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'Type of capture',
example: 'angle',
})
captureType: string;
@ApiProperty({
description: 'Key of what was captured',
example: 'front',
})
captureKey: string;
@ApiProperty({
description: 'File URL',
example: 'http://localhost:3000/files/captures/front-1234567890.jpg',
})
fileUrl: string;
@ApiProperty({
description: 'Whether all captures are now complete',
example: false,
})
allCapturesComplete: boolean;
@ApiProperty({
description: 'Current workflow step',
example: 'CAPTURE_PART_DAMAGES',
})
currentStep: string;
@ApiProperty({
description: 'Success message',
example: 'Angle captured successfully. 6 captures remaining.',
})
message: string;
}

View File

@@ -0,0 +1,163 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO for required document item
*/
export class RequiredDocumentItem {
@ApiProperty({
description: 'Document key/type',
example: 'car_green_card',
})
key: string;
@ApiProperty({
description: 'Display label in Farsi',
example: 'کارت سبز خودرو',
})
label_fa: string;
@ApiProperty({
description: 'Display label in English',
example: 'Car Green Card',
})
label_en: string;
@ApiProperty({
description: 'Whether this document has been uploaded',
example: false,
})
uploaded: boolean;
@ApiProperty({
description: 'Category of the document',
example: 'general',
enum: ['general', 'damaged_party', 'guilty_party'],
})
category: string;
}
/**
* DTO for car angle item
*/
export class CarAngleItem {
@ApiProperty({
description: 'Angle key',
example: 'front',
enum: ['front', 'back', 'left', 'right'],
})
key: string;
@ApiProperty({
description: 'Display label in Farsi',
example: 'جلو',
})
label_fa: string;
@ApiProperty({
description: 'Display label in English',
example: 'Front',
})
label_en: string;
@ApiProperty({
description: 'Whether this angle has been captured',
example: false,
})
captured: boolean;
}
/**
* DTO for damaged part item
*/
export class DamagedPartItem {
@ApiProperty({
description: 'Part key',
example: 'hood',
})
key: string;
@ApiProperty({
description: 'Display label in Farsi',
example: 'کاپوت',
})
label_fa: string;
@ApiProperty({
description: 'Display label in English',
example: 'Hood',
})
label_en: string;
@ApiProperty({
description: 'Whether this part has been captured',
example: false,
})
captured: boolean;
}
/**
* Response DTO for capture requirements V2
*/
export class GetCaptureRequirementsV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'Public ID',
example: 'A14235',
})
publicId: string;
@ApiProperty({
description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
})
currentStep: string;
@ApiProperty({
description: 'List of required documents to upload',
type: [RequiredDocumentItem],
})
requiredDocuments: RequiredDocumentItem[];
@ApiProperty({
description: 'List of car angles to capture',
type: [CarAngleItem],
})
carAngles: CarAngleItem[];
@ApiProperty({
description: 'List of damaged parts to capture',
type: [DamagedPartItem],
})
damagedParts: DamagedPartItem[];
@ApiProperty({
description: 'Total number of items remaining',
example: 18,
})
totalRemaining: number;
@ApiProperty({
description: 'Summary of progress',
example: {
documentsUploaded: 2,
documentsTotal: 13,
anglesCapture: 0,
anglesTotal: 4,
partsCapture: 0,
partsTotal: 3,
},
})
progress: {
documentsUploaded: number;
documentsTotal: number;
anglesCaptured: number;
anglesTotal: number;
partsCaptured: number;
partsTotal: number;
};
}

View File

@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ClaimDetailsV2ResponseDto {
@ApiProperty({ description: 'Claim case ID' })
claimRequestId: string;
@ApiProperty({ description: 'Public ID' })
publicId: string;
@ApiProperty({ description: 'Request number' })
requestNo: string;
@ApiProperty({ description: 'Overall case status' })
status: string;
@ApiProperty({ description: 'Claim damage status' })
claimStatus: string;
@ApiProperty({ description: 'Current workflow step' })
currentStep: string;
@ApiPropertyOptional({ description: 'Next workflow step' })
nextStep?: string;
@ApiPropertyOptional({ description: 'Blame request ID' })
blameRequestId?: string;
@ApiPropertyOptional({ description: 'Blame request number' })
blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Owner info' })
owner?: {
userId: string;
fullName?: string;
};
@ApiPropertyOptional({ description: 'Vehicle snapshot' })
vehicle?: {
carName?: string;
carModel?: string;
carType?: string;
plate?: any;
};
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
selectedParts?: string[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[];
@ApiPropertyOptional({ description: 'Bank info (masked)' })
money?: {
sheba?: string;
nationalCodeOfOwner?: string;
};
@ApiPropertyOptional({ description: 'Required documents status' })
requiredDocuments?: Record<string, { uploaded: boolean; fileId?: string }>;
@ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiProperty({ description: 'Created at' })
createdAt: string;
@ApiProperty({ description: 'Updated at' })
updatedAt: string;
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from "@nestjs/swagger";
export class CreateClaimFromBlameResponseDto {
@ApiProperty({
example: "67fa10c259e15f231a2d1aae",
description: "Created claim request ID",
})
claimRequestId: string;
@ApiProperty({
example: "A14235",
description: "Shared public ID from blame case",
})
publicId: string;
@ApiProperty({
example: "SELECTING_OUTER_PARTS",
description: "Current status after creation",
})
status: string;
@ApiProperty({
example: "Claim request created successfully",
})
message: string;
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID', example: '507f1f77bcf86cd799439011' })
claimRequestId: string;
@ApiProperty({ description: 'Public ID shared across blame and claim', example: 'A14235' })
publicId: string;
@ApiProperty({ description: 'Claim request number', example: 'CL12345' })
requestNo: string;
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
status: string;
@ApiProperty({ description: 'Claim damage determination status', example: 'PENDING' })
claimStatus: string;
@ApiProperty({ description: 'Current workflow step', example: 'USER_SUBMISSION_COMPLETE' })
currentStep: string;
@ApiProperty({ description: 'Creation date', example: '2026-02-22T10:00:00.000Z' })
createdAt: string;
@ApiProperty({ description: 'Blame request ID this claim originated from' })
blameRequestId?: string;
}
export class GetMyClaimsV2ResponseDto {
@ApiProperty({ description: 'List of user claims', type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count', example: 5 })
total: number;
}

View File

@@ -0,0 +1,126 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, Matches, Length, ArrayMaxSize } from 'class-validator';
/**
* Enum for valid other (non-body) car parts that can be damaged
*/
export enum OtherCarPart {
ENGINE = 'engine',
SUSPENSION = 'suspension',
BRAKE_SYSTEM = 'brake_system',
ELECTRICAL = 'electrical',
RADIATOR = 'radiator',
TRANSMISSION = 'transmission',
EXHAUST = 'exhaust',
HEADLIGHT = 'headlight',
TAILLIGHT = 'taillight',
MIRROR = 'mirror',
GLASS = 'glass',
}
/**
* V2 DTO for selecting other damaged parts and bank information
* Step 3 of claim workflow
*/
export class SelectOtherPartsV2Dto {
@ApiPropertyOptional({
description: 'Array of selected other damaged parts (non-body parts)',
example: ['engine', 'suspension', 'headlight'],
enum: OtherCarPart,
isArray: true,
maxItems: 11,
required: false,
})
@IsOptional()
@IsArray({ message: 'otherParts must be an array' })
@ArrayMaxSize(11, { message: 'Maximum 11 other parts can be selected' })
@IsEnum(OtherCarPart, {
each: true,
message: 'Invalid part name. Must be one of the valid other car parts',
})
otherParts?: OtherCarPart[];
@ApiProperty({
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
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;
@ApiProperty({
description: 'National code of the owner - 10 digits',
example: '1234567890',
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10,
})
@IsNotEmpty({ message: 'National code of owner 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;
}
/**
* Response DTO for select other parts V2
*/
export class SelectOtherPartsV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'Public ID shared across blame and claim',
example: 'A14235',
})
publicId: string;
@ApiProperty({
description: 'Array of selected other damaged parts',
example: ['engine', 'suspension'],
required: false,
})
otherParts?: string[];
@ApiProperty({
description: 'Sheba number (masked for security)',
example: 'IR12************1234',
})
shebaNumber: string;
@ApiProperty({
description: 'National code of owner (masked)',
example: '12******90',
})
nationalCodeOfOwner: string;
@ApiProperty({
description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
})
currentStep: string;
@ApiProperty({
description: 'Next possible workflow step',
example: 'CAPTURE_PART_DAMAGES',
})
nextStep: string;
@ApiProperty({
description: 'Success message',
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
})
message: string;
}

View File

@@ -0,0 +1,96 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
/**
* Enum for valid outer car parts that can be damaged
* Follows the naming convention from workflow step definition
*/
export enum OuterCarPart {
// Hood
HOOD = 'hood',
// Doors
FRONT_RIGHT_DOOR = 'front_right_door',
FRONT_LEFT_DOOR = 'front_left_door',
REAR_RIGHT_DOOR = 'rear_right_door',
REAR_LEFT_DOOR = 'rear_left_door',
// Bumpers
FRONT_BUMPER = 'front_bumper',
REAR_BUMPER = 'rear_bumper',
// Fenders
FRONT_RIGHT_FENDER = 'front_right_fender',
FRONT_LEFT_FENDER = 'front_left_fender',
REAR_RIGHT_FENDER = 'rear_right_fender',
REAR_LEFT_FENDER = 'rear_left_fender',
// Trunk & Roof
TRUNK = 'trunk',
ROOF = 'roof',
}
/**
* V2 DTO for selecting damaged outer car parts
* Much cleaner than the nested boolean structure
*/
export class SelectOuterPartsV2Dto {
@ApiProperty({
description: 'Array of selected damaged outer car parts',
example: ['hood', 'front_right_door', 'rear_bumper', 'roof'],
enum: OuterCarPart,
isArray: true,
minItems: 1,
maxItems: 13,
})
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
@IsArray({ message: 'selectedParts must be an array' })
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
@IsEnum(OuterCarPart, {
each: true,
message: 'Invalid part name. Must be one of the valid outer car parts',
})
selectedParts: OuterCarPart[];
}
/**
* Response DTO for select outer parts V2
*/
export class SelectOuterPartsV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'Public ID shared across blame and claim',
example: 'A14235',
})
publicId: string;
@ApiProperty({
description: 'Array of selected damaged parts',
example: ['hood', 'front_right_door', 'rear_bumper'],
})
selectedParts: string[];
@ApiProperty({
description: 'Current workflow step',
example: 'SELECT_OUTER_PARTS',
})
currentStep: string;
@ApiProperty({
description: 'Next possible workflow step',
example: 'SELECT_OTHER_PARTS',
})
nextStep: string;
@ApiProperty({
description: 'Success message',
example: 'Outer parts selected successfully. Please proceed to select other parts.',
})
message: string;
}

View File

@@ -0,0 +1,67 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
/**
* V2 DTO for uploading required document
*/
export class UploadRequiredDocumentV2Dto {
@ApiProperty({
description: 'Document type/key',
example: 'car_green_card',
enum: ClaimRequiredDocumentType,
})
@IsNotEmpty({ message: 'Document key is required' })
@IsEnum(ClaimRequiredDocumentType, {
message: 'Invalid document type',
})
documentKey: ClaimRequiredDocumentType;
@ApiProperty({
type: 'string',
format: 'binary',
description: 'Image file (JPG, PNG, PDF)',
})
file: Express.Multer.File;
}
/**
* Response DTO for upload required document V2
*/
export class UploadRequiredDocumentV2ResponseDto {
@ApiProperty({
description: 'Claim request ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'Document key that was uploaded',
example: 'car_green_card',
})
documentKey: string;
@ApiProperty({
description: 'File URL',
example: 'http://localhost:3000/files/documents/car-green-card-1234567890.jpg',
})
fileUrl: string;
@ApiProperty({
description: 'Whether all required documents are now uploaded',
example: false,
})
allDocumentsUploaded: boolean;
@ApiProperty({
description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
})
currentStep: string;
@ApiProperty({
description: 'Success message',
example: 'Document uploaded successfully. 12 documents remaining.',
})
message: string;
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model, Types } from "mongoose";
import { ClaimCase, ClaimCaseDocument } from "../schema/claim-cases.schema";
@Injectable()
export class ClaimCaseDbService {
constructor(
@InjectModel(ClaimCase.name)
private readonly claimCaseModel: Model<ClaimCaseDocument>,
) {}
async create(payload: Partial<ClaimCase>): Promise<ClaimCaseDocument> {
return this.claimCaseModel.create(payload);
}
async findById(id: string | Types.ObjectId): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findById(id);
}
async findOne(filter: FilterQuery<ClaimCase>): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findOne(filter);
}
async findByIdAndUpdate(
id: string | Types.ObjectId,
update: any,
): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
}
async find(
filter: FilterQuery<ClaimCase>,
options?: { lean?: boolean; select?: string },
): Promise<ClaimCaseDocument[] | Record<string, unknown>[]> {
if (options?.lean) {
const query = options?.select
? this.claimCaseModel.find(filter).select(options.select).lean()
: this.claimCaseModel.find(filter).lean();
return query.exec() as Promise<Record<string, unknown>[]>;
}
const query = options?.select
? this.claimCaseModel.find(filter).select(options.select)
: this.claimCaseModel.find(filter);
return query.exec() as Promise<ClaimCaseDocument[]>;
}
async countByFilter(filter: FilterQuery<ClaimCase>): Promise<number> {
return this.claimCaseModel.countDocuments(filter);
}
}

View File

@@ -20,6 +20,6 @@ export class VideoCaptureDbService {
} }
async findById(id: string): Promise<VideoCaptureModel | null> { async findById(id: string): Promise<VideoCaptureModel | null> {
return this.videoCapture.findById(id).lean(); return this.videoCapture.findById(id);
} }
} }

View File

@@ -0,0 +1,31 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
@Schema({ _id: false })
export class ClaimDamageSelection {
/**
* V2: Array of selected damaged outer car part names
* Examples: ['hood', 'front_right_door', 'rear_bumper']
*/
@Prop({ type: [String], default: [] })
selectedParts?: string[];
/**
* V2: Array of selected other (non-body) damaged parts
* Examples: ['engine', 'suspension', 'headlight']
*/
@Prop({ type: [String], default: [] })
otherParts?: string[];
/**
* Legacy fields - kept for backward compatibility
*/
@Prop({ type: [CarDamagePartModel] })
legacyParts?: CarDamagePartModel[];
@Prop({ type: CarDamagePartOtherModel })
legacyOtherParts?: CarDamagePartOtherModel;
}
export const ClaimDamageSelectionSchema =
SchemaFactory.createForClass(ClaimDamageSelection);

View File

@@ -0,0 +1,202 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "mongoose";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
@Schema({ _id: false })
export class ClaimPartPricing {
@Prop({ type: String })
partId: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
typeOfDamage?: string;
@Prop({ type: MongooseSchema.Types.Mixed })
price?: string | number;
@Prop({ type: MongooseSchema.Types.Mixed })
salary?: string | number;
@Prop({ type: MongooseSchema.Types.Mixed })
totalPayment?: string | number;
@Prop({ type: MongooseSchema.Types.Mixed })
daghi?: string | number;
@Prop({ type: Boolean, default: false })
factorNeeded?: boolean;
@Prop({ type: Types.ObjectId })
factorLink?: Types.ObjectId;
@Prop({ type: String, enum: FactorStatus, default: null })
factorStatus?: FactorStatus | null;
@Prop({ type: String, default: null })
rejectionReason?: string | null;
}
export const ClaimPartPricingSchema =
SchemaFactory.createForClass(ClaimPartPricing);
@Schema({ _id: false })
export class ClaimExpertActor {
@Prop({ type: String })
actorName?: string;
/**
* Kept as string to support existing queries like:
* `"damageExpertReply.actorDetail.actorId": actor.sub`
*/
@Prop({ type: String })
actorId?: string;
}
export const ClaimExpertActorSchema =
SchemaFactory.createForClass(ClaimExpertActor);
@Schema({ _id: false })
export class ClaimUserComment {
@Prop({ type: Boolean, default: null })
isAccept?: boolean | null;
@Prop({ type: Types.ObjectId })
signDetailId?: Types.ObjectId;
}
export const ClaimUserCommentSchema =
SchemaFactory.createForClass(ClaimUserComment);
@Schema({ _id: false })
export class ClaimExpertReply {
@Prop({ type: String })
description?: string;
@Prop({ type: ClaimExpertActorSchema })
actorDetail?: ClaimExpertActor;
@Prop({ type: [ClaimPartPricingSchema], default: [] })
parts?: ClaimPartPricing[];
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
@Prop({ type: ClaimUserCommentSchema })
userComment?: ClaimUserComment;
}
export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply);
@Schema({ _id: false })
export class ClaimUserObjection {
@Prop({ type: String, required: true })
partId: string;
@Prop({ type: String })
reason?: string;
@Prop({ type: String })
partPrice?: string;
@Prop({ type: String })
partSalary?: string;
@Prop({ type: String })
typeOfDamage?: string;
}
export const ClaimUserObjectionSchema =
SchemaFactory.createForClass(ClaimUserObjection);
@Schema({ _id: false })
export class ClaimResendRequest {
@Prop({ type: String })
resendDescription?: string;
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendDocuments?: any[];
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendCarParts?: any[];
}
export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest);
@Schema({ _id: false })
export class ClaimUserResendPayload {
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
documents?: any[];
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
carParts?: any[];
}
export const ClaimUserResendPayloadSchema =
SchemaFactory.createForClass(ClaimUserResendPayload);
@Schema({ _id: false })
export class ClaimFileRating {
@Prop({ type: Number, min: 0, max: 5 })
collisionMethodAccuracy?: number;
@Prop({ type: Number, min: 0, max: 5 })
evaluationTimeliness?: number;
@Prop({ type: Number, min: 0, max: 5 })
accidentCauseAccuracy?: number;
@Prop({ type: Number, min: 0, max: 5 })
guiltyVehicleIdentification?: number;
}
export const ClaimFileRatingSchema =
SchemaFactory.createForClass(ClaimFileRating);
@Schema({ _id: false })
export class ClaimPriceDrop {
@Prop({ type: Number })
total?: number;
@Prop({ type: Number })
carPrice?: number;
@Prop({ type: Number })
carModel?: number;
@Prop({ type: [Number], default: [] })
carValue?: number[];
@Prop({ type: Number })
sumOfSeverity?: number;
}
export const ClaimPriceDropSchema = SchemaFactory.createForClass(ClaimPriceDrop);
@Schema({ _id: false })
export class ClaimEvaluation {
@Prop({ type: String, enum: UserReplyEnum, default: null })
effectedUserReply?: UserReplyEnum | null;
@Prop({ type: ClaimExpertReplySchema })
damageExpertReply?: ClaimExpertReply | null;
@Prop({ type: ClaimExpertReplySchema })
damageExpertReplyFinal?: ClaimExpertReply | null;
@Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema })
objection?: ClaimUserObjection;
@Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload;
@Prop({ type: ClaimFileRatingSchema })
rating?: ClaimFileRating;
@Prop({ type: String })
visitLocation?: string;
@Prop({ type: ClaimPriceDropSchema })
priceDrop?: ClaimPriceDrop;
}
export const ClaimEvaluationSchema =
SchemaFactory.createForClass(ClaimEvaluation);

View File

@@ -0,0 +1,65 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
@Schema({ _id: false })
export class ClaimPlate {
@Prop({ type: Number })
leftDigits?: number;
@Prop({ type: String })
centerAlphabet?: string;
@Prop({ type: Number })
centerDigits?: number;
@Prop({ type: Number })
ir?: number;
@Prop({ type: String })
nationalCode?: string;
}
export const ClaimPlateSchema = SchemaFactory.createForClass(ClaimPlate);
@Schema({ _id: false })
export class ClaimVehicleSnapshot {
@Prop({ type: String })
carName?: string;
@Prop({ type: String })
carModel?: string;
@Prop({ type: String })
carType?: string;
@Prop({ type: Boolean })
isNewCar?: boolean;
@Prop({ type: ClaimPlateSchema })
plate?: ClaimPlate;
}
export const ClaimVehicleSnapshotSchema =
SchemaFactory.createForClass(ClaimVehicleSnapshot);
@Schema({ _id: false })
export class ClaimOwner {
@Prop({ type: Types.ObjectId })
userId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
userClientKey?: Types.ObjectId;
@Prop({ type: String })
fullName?: string;
}
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);
@Schema({ _id: false })
export class ClaimMoney {
@Prop({ type: String, trim: true })
sheba?: string;
@Prop({ type: String, trim: true })
nationalCodeOfInsurer?: string;
}
export const ClaimMoneySchema = SchemaFactory.createForClass(ClaimMoney);

View File

@@ -0,0 +1,60 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { AiImagesModel } from "./ai-image.schema";
import { CarGreenCardModel } from "./car-green-card.schema";
import { ImageRequiredModel } from "./image-required.schema";
@Schema({ _id: false })
export class CapturedImage {
@Prop({ type: String })
path?: string;
@Prop({ type: String })
fileName?: string;
@Prop({ type: String })
url?: string;
@Prop({ type: Date })
capturedAt?: Date;
}
export const CapturedImageSchema = SchemaFactory.createForClass(CapturedImage);
@Schema({ _id: false })
export class ClaimMedia {
@Prop({ type: CarGreenCardModel })
carGreenCard?: CarGreenCardModel;
@Prop({ type: ImageRequiredModel })
imageRequired?: ImageRequiredModel;
@Prop({ type: AiImagesModel })
aiImages?: AiImagesModel;
@Prop({ type: Types.ObjectId })
videoCaptureId?: Types.ObjectId;
/**
* V2: Car angles captures (front, back, left, right)
* Map of angle key to captured image
*/
@Prop({
type: Map,
of: CapturedImageSchema,
default: () => ({}),
})
carAngles?: Map<string, CapturedImage>;
/**
* V2: Damaged parts captures
* Map of part key to captured image
*/
@Prop({
type: Map,
of: CapturedImageSchema,
default: () => ({}),
})
damagedParts?: Map<string, CapturedImage>;
}
export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia);

View File

@@ -0,0 +1,15 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { AccidentInfo, AccidentInfoSchema } from "src/request-management/entities/schema/accidentInformation.type";
import { Party, PartySchema } from "src/request-management/entities/schema/partyRole.enum";
@Schema({ _id: false })
export class ClaimCaseSnapshot {
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties?: Party[];
}
export const ClaimCaseSnapshotSchema =
SchemaFactory.createForClass(ClaimCaseSnapshot);

View File

@@ -0,0 +1,39 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
@Schema({ _id: false })
export class ClaimActorLock {
@Prop({ type: Types.ObjectId })
actorId: Types.ObjectId;
@Prop({ type: String })
actorName: string;
@Prop({ type: String, default: "damage_expert" })
actorRole: "damage_expert" | "expert" | "admin";
}
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
@Schema({ _id: false })
export class ClaimWorkflow {
@Prop({ type: String, enum: ClaimWorkflowStep })
currentStep: ClaimWorkflowStep;
@Prop({ type: String, enum: ClaimWorkflowStep })
nextStep: ClaimWorkflowStep;
@Prop({ type: [String], enum: ClaimWorkflowStep, default: [] })
completedSteps?: ClaimWorkflowStep[];
@Prop({ type: Boolean, default: false })
locked?: boolean;
@Prop({ type: Date })
lockedAt?: Date;
@Prop({ type: ClaimActorLockSchema })
lockedBy?: ClaimActorLock;
}
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);

View File

@@ -0,0 +1,147 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type";
import {
ClaimDamageSelection,
ClaimDamageSelectionSchema,
} from "./claim-case.damage.schema";
import {
ClaimMoney,
ClaimMoneySchema,
ClaimOwner,
ClaimOwnerSchema,
ClaimVehicleSnapshot,
ClaimVehicleSnapshotSchema,
} from "./claim-case.identity.schema";
import { ClaimMedia, ClaimMediaSchema } from "./claim-case.media.schema";
import {
ClaimEvaluation,
ClaimEvaluationSchema,
} from "./claim-case.evaluation.schema";
import {
ClaimCaseSnapshot,
ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
@Schema({ _id: false })
export class RequiredDocumentRef {
@Prop({ type: Types.ObjectId })
fileId?: Types.ObjectId;
@Prop({ type: String })
filePath?: string;
@Prop({ type: String })
fileName?: string;
@Prop({ type: Boolean, default: true })
uploaded?: boolean;
@Prop({ type: Date })
uploadedAt?: Date;
}
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(RequiredDocumentRef);
@Schema({
collection: "claimCases",
timestamps: true,
id: true,
versionKey: false,
})
export class ClaimCase {
@Prop({ required: true, unique: true, index: true, trim: true })
requestNo: string;
/**
* Human-friendly shared id across blame+claim (e.g. A14235).
* Generated once on blame creation and copied into claim.
*/
@Prop({ required: true, unique: true, index: true, trim: true })
publicId: string;
/**
* Overall case status (user flow + expert flow progression)
*/
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED })
status: ClaimCaseStatus;
/**
* Claim damage determination status (expert assessment)
*/
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus;
/**
* Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
*/
@Prop({ type: Types.ObjectId, index: true })
blameRequestId?: Types.ObjectId;
@Prop({ type: String, index: true })
blameRequestNo?: string;
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
workflow?: ClaimWorkflow;
@Prop({ type: ClaimOwnerSchema, default: () => ({}) })
owner?: ClaimOwner;
@Prop({ type: ClaimVehicleSnapshotSchema, default: () => ({}) })
vehicle?: ClaimVehicleSnapshot;
@Prop({ type: ClaimMoneySchema })
money?: ClaimMoney;
@Prop({ type: Number })
claimNo?: number;
@Prop({ type: Number })
claimId?: number;
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
damage?: ClaimDamageSelection;
@Prop({ type: ClaimMediaSchema, default: () => ({}) })
media?: ClaimMedia;
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
evaluation?: ClaimEvaluation;
/**
* Optional “read-optimized” copy of fields from blame/request side.
* Source of truth remains `blameRequestId`.
*/
@Prop({ type: ClaimCaseSnapshotSchema })
snapshot?: ClaimCaseSnapshot;
/**
* For quick “has user uploaded X?” checks. Values point to docs in the
* `claim-required-documents` collection.
*/
@Prop({
type: Map,
of: RequiredDocumentRefSchema,
default: () => ({}),
})
requiredDocuments?: Map<string, RequiredDocumentRef>;
@Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[];
/**
* Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
legacy?: any;
}
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);

View File

@@ -241,6 +241,22 @@ export class ClaimRequestManagementModel {
@Prop({ type: RequestManagementModel, unique: true }) @Prop({ type: RequestManagementModel, unique: true })
blameFile: RequestManagementModel; blameFile: RequestManagementModel;
/**
* Human-friendly shared id across blame+claim (e.g. A14235).
* Copied from the source blame request.
*
* NOTE: `sparse` keeps existing documents without this field valid.
*/
@Prop({ type: String, unique: true, index: true, sparse: true, trim: true })
publicId?: string;
/**
* Explicit reference to the blame request id (in addition to embedded blameFile).
* This will be the preferred reference going forward.
*/
@Prop({ type: Types.ObjectId, index: true })
blameRequestId?: Types.ObjectId;
@Prop({ type: Number, default: 100 }) @Prop({ type: Number, default: 100 })
requestNumber?: number; requestNumber?: number;

View File

@@ -101,3 +101,27 @@ export class AllRequestDtoRs {
this.data = requests.map((r) => new AllRequestDto(r)); this.data = requests.map((r) => new AllRequestDto(r));
} }
} }
/** V2: list item from blameCases (BlameRequest) for expert panel */
export class AllRequestDtoV2 {
requestId: string;
status: string;
userComment: null;
requestCode: string;
date: string;
time: string;
updatedAtDate: string;
updatedAtTime: string;
lockFile: boolean;
lockTime: string | null;
type: string;
blameStatus: string;
partiesInitialForms: { firstParty: string; secondParty: string };
}
export class AllRequestDtoRsV2 {
data: AllRequestDtoV2[];
constructor(items: AllRequestDtoV2[]) {
this.data = items;
}
}

View File

@@ -3,42 +3,81 @@ import { Types } from "mongoose";
import { BlameDocumentType } from "src/request-management/entities/schema/blame-document.schema"; import { BlameDocumentType } from "src/request-management/entities/schema/blame-document.schema";
export class AccidentWayIF { export class AccidentWayIF {
@ApiProperty({ required: true }) @ApiProperty({ required: true, example: "9" })
id: string; id: string;
@ApiProperty({ required: true }) @ApiProperty({ required: true, example: "جلو به پهلو" })
label: string; label: string;
} }
export class AccidentReasonIF { export class AccidentReasonIF {
@ApiProperty({ required: true }) @ApiProperty({ required: true, example: "23" })
id: string; id: string;
@ApiProperty({ required: true }) @ApiProperty({ required: true, example: "عدم رعايت فاصله جانبی" })
label: string; label: string;
@ApiProperty({ required: true }) @ApiProperty({ required: true, example: 3 })
fanavaran: number; fanavaran: number;
} }
export class FieldsInterface { export class FieldsInterface {
@ApiProperty({ required: true }) @ApiProperty({
required: true,
example: { id: "9", label: "جلو به پهلو" },
})
accidentWay: AccidentWayIF; accidentWay: AccidentWayIF;
@ApiProperty({ required: true }) @ApiProperty({
required: true,
example: {
id: "23",
label: "عدم رعايت فاصله جانبی",
fanavaran: 3,
},
})
accidentReason: AccidentReasonIF; accidentReason: AccidentReasonIF;
@ApiProperty({ required: true }) @ApiProperty({
required: true,
example: {
id: "3",
label: "برخورد یک وسیله نقلیه با وسیله نقلیه پارک شده",
},
})
accidentType: AccidentWayIF; accidentType: AccidentWayIF;
} }
export class SubmitReplyDto { export class SubmitReplyDto {
@ApiProperty({ required: true }) @ApiProperty({
required: true,
example: "Expert's decision explanation",
description: "Detailed explanation of the expert's decision",
})
description: string; description: string;
@ApiProperty({ required: true, type: Types.ObjectId }) @ApiProperty({
required: true,
type: String,
example: "507f1f77bcf86cd799439011",
description: "ObjectId of the guilty party",
})
guiltyUserId: Types.ObjectId; guiltyUserId: Types.ObjectId;
@ApiProperty({ required: true }) @ApiProperty({
required: true,
example: {
accidentWay: { id: "9", label: "جلو به پهلو" },
accidentReason: {
id: "23",
label: "عدم رعايت فاصله جانبی",
fanavaran: 3,
},
accidentType: {
id: "3",
label: "برخورد یک وسیله نقلیه با وسیله نقلیه پارک شده",
},
},
})
fields: FieldsInterface; fields: FieldsInterface;
} }

View File

@@ -0,0 +1,45 @@
import { ApiProperty } from "@nestjs/swagger";
import { Types } from "mongoose";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
export class PartyResendRequestDto {
@ApiProperty({
required: true,
type: String,
example: "507f1f77bcf86cd799439011",
description: "userId (person.userId) of the party member to request documents from",
})
partyId: Types.ObjectId;
@ApiProperty({
required: true,
isArray: true,
enum: ResendItemType,
example: [ResendItemType.DRIVING_LICENSE, ResendItemType.CAR_CERTIFICATE],
description: "Array of items to request from this party",
})
requestedItems: ResendItemType[];
@ApiProperty({
required: false,
example: "Please resend your driving license and car certificate with better quality",
description: "Optional explanation for why these items are needed",
})
description?: string;
}
export class ResendRequestDto {
@ApiProperty({
required: true,
type: [PartyResendRequestDto],
description: "Array of resend requests (can include first party, second party, or both)",
example: [
{
partyId: "507f1f77bcf86cd799439011",
requestedItems: ["drivingLicense", "carCertificate"],
description: "Please resend with better quality",
},
],
})
parties: PartyResendRequestDto[];
}

View File

@@ -3,12 +3,13 @@ import { ClientModule } from "src/client/client.module";
import { PlatesModule } from "src/plates/plates.module"; import { PlatesModule } from "src/plates/plates.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ExpertBlameController } from "./expert-blame.controller"; import { ExpertBlameController } from "./expert-blame.controller";
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
import { ExpertBlameService } from "./expert-blame.service"; import { ExpertBlameService } from "./expert-blame.service";
import { RequestManagementModule } from "src/request-management/request-management.module"; import { RequestManagementModule } from "src/request-management/request-management.module";
@Module({ @Module({
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule], imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule],
controllers: [ExpertBlameController], controllers: [ExpertBlameController, ExpertBlameV2Controller],
providers: [ExpertBlameService], providers: [ExpertBlameService],
exports: [ExpertBlameService], exports: [ExpertBlameService],
}) })

View File

@@ -1,12 +1,19 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpException,
Injectable, Injectable,
InternalServerErrorException,
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { AllRequestDtoRs } from "./dto/all-request.dto"; import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import {
AllRequestDtoRs,
AllRequestDtoV2,
AllRequestDtoRsV2,
} from "./dto/all-request.dto";
import { UserType } from "src/Types&Enums/userType.enum"; import { UserType } from "src/Types&Enums/userType.enum";
import { SubmitReplyDto } from "./dto/reply.dto"; import { SubmitReplyDto } from "./dto/reply.dto";
import { Types } from "mongoose"; import { Types } from "mongoose";
@@ -14,7 +21,13 @@ import { BlameVideoDbService } from "src/request-management/entities/db-service/
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.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 { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
import { buildFileLink } from "src/helpers/urlCreator"; import { buildFileLink } from "src/helpers/urlCreator";
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
import { ResendRequestDto } from "./dto/resend.dto";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
@@ -28,11 +41,26 @@ interface CheckedRequestEntry {
[key: string]: any; [key: string]: any;
} }
function statementToFormKey(
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
},
): string | null {
if (!statement) return null;
if (statement.admitsGuilt) return "imGuilty";
if (statement.claimsDamage) return "imDamaged";
if (statement.acceptsExpertOpinion) return "expertOpinion";
return null;
}
@Injectable() @Injectable()
export class ExpertBlameService { export class ExpertBlameService {
private readonly logger = new Logger(ExpertBlameService.name); private readonly logger = new Logger(ExpertBlameService.name);
constructor( constructor(
private readonly requestManagementDbService: RequestManagementDbService, private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
private readonly blameVideoDbService: BlameVideoDbService, private readonly blameVideoDbService: BlameVideoDbService,
private readonly blameVoiceDbService: BlameVoiceDbService, private readonly blameVoiceDbService: BlameVoiceDbService,
@@ -148,6 +176,108 @@ export class ExpertBlameService {
return new AllRequestDtoRs(visibleRequests); return new AllRequestDtoRs(visibleRequests);
} }
/**
* V2: List blame cases for current expert.
* Shows:
* 1. Fresh requests (WAITING_FOR_EXPERT with no decision)
* 2. Requests where current expert made the decision
* Does NOT show requests decided by other experts.
*/
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try {
const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find(
{
blameStatus: BlameStatus.DISAGREEMENT,
},
{ lean: true },
);
// Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
const status = doc.status as string;
const decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId;
const hasDecision = !!decision?.decision;
// Fresh request (no decision yet)
if (status === CaseStatus.WAITING_FOR_EXPERT && !hasDecision) {
return true;
}
// Request decided by current expert
if (decidedByExpertId && String(decidedByExpertId) === expertId) {
return true;
}
// Locked by current expert but no decision yet
const lockedBy = decision?.resend?.requestedByExpertId || (doc.workflow as any)?.lockedBy?.actorId;
if (status === CaseStatus.WAITING_FOR_EXPERT && lockedBy && String(lockedBy) === expertId) {
return true;
}
return false;
});
const items: AllRequestDtoV2[] = visibleCases.map(
(doc) => this.mapBlameRequestToListItemV2(doc),
);
return new AllRequestDtoRsV2(items);
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error("findAllV2 failed", error instanceof Error ? error.stack : String(error));
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases",
);
}
}
private mapBlameRequestToListItemV2(
doc: Record<string, unknown>,
): AllRequestDtoV2 {
const createdAt = doc.createdAt ? new Date(doc.createdAt as string) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string) : new Date();
const [date, time] = toJalaliDateAndTime(createdAt);
const [updatedAtDate, updatedAtTime] = toJalaliDateAndTime(updatedAt);
const workflow = (doc.workflow ?? {}) as Record<string, unknown>;
const parties = (doc.parties ?? []) as Array<{
role?: string;
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const firstParty = parties.find((p) => p.role === "FIRST");
const secondParty = parties.find((p) => p.role === "SECOND");
return {
requestId: String(doc._id),
status: String(doc.status ?? ""),
userComment: null,
requestCode: String(doc.publicId ?? ""),
date,
time,
updatedAtDate,
updatedAtTime,
lockFile: Boolean(workflow.locked),
lockTime: workflow.lockedAt
? new Date(workflow.lockedAt as string).toISOString()
: null,
type: String(doc.type ?? "THIRD_PARTY"),
blameStatus: String(doc.blameStatus ?? ""),
partiesInitialForms: {
firstParty: statementToFormKey(firstParty?.statement) ?? "",
secondParty: statementToFormKey(secondParty?.statement) ?? "",
},
};
}
public unlockApi(request, timer) { public unlockApi(request, timer) {
return setTimeout(async () => { return setTimeout(async () => {
try { try {
@@ -374,6 +504,92 @@ export class ExpertBlameService {
return request; return request;
} }
/**
* V2: Get blame case details by id from blameCases collection.
* Excludes history. Returns only nonCAR_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<Record<string, unknown>> {
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: Check if expert has permission to view this request
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
// Allow if:
// 1. No decision yet (fresh request)
// 2. Decision made by current expert
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<string, unknown>;
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 lockRequest(requestId: string, actorDetail) { async lockRequest(requestId: string, actorDetail) {
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
@@ -418,6 +634,340 @@ export class ExpertBlameService {
return { _id: requestId, lock: true }; return { _id: requestId, lock: true };
} }
/**
* V2: Lock blame case for 15 minutes (blameCases collection).
* Sets workflow.locked, workflow.lockedBy, workflow.lockedAt.
* Checks if existing lock is expired and allows re-locking.
*/
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
try {
const now = new Date();
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
// First, check the current state
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate request is available for expert review
if (
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
request.blameStatus !== BlameStatus.DISAGREEMENT
) {
throw new BadRequestException(
"Request is not available for expert review",
);
}
// Check if locked and not expired
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(request.workflow.lockedBy?.actorId);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
);
} else {
throw new BadRequestException(
"Request is currently locked by another expert",
);
}
}
// Lock expired, allow re-locking (continue below)
}
}
// Lock the request (either unlocked or expired lock)
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
{
$set: {
"workflow.locked": true,
"workflow.lockedAt": now,
"workflow.lockedBy": {
actorId: new Types.ObjectId(actorDetail.sub),
actorName: actorDetail.fullName || "Unknown Expert",
actorRole: "expert",
},
},
},
);
if (!updateResult) {
throw new InternalServerErrorException("Failed to lock the request");
}
// Update expert stats (reusing existing helper)
await this.updateDamageExpertStats(actorDetail.sub, requestId, "checked");
return { _id: requestId, lock: true };
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"lockRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to lock blame case",
);
}
}
/**
* V2: Request parties to resend documents/evidence (blameCases collection).
* Expert can request one or both parties to provide additional or better quality evidence.
*/
async resendRequestV2(
requestId: string,
resendDto: ResendRequestDto,
actorId: string,
): Promise<{ requestId: string; status: string }> {
try {
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate request is locked by current expert
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before requesting document resend.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
if (lockedByActorId !== actorId) {
throw new ForbiddenException(
"Access denied. You are not the locked expert for this request.",
);
}
// Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
if (Date.now() > lockExpiryTime) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.",
);
}
}
// Validate at least one party is specified
if (!resendDto.parties || resendDto.parties.length === 0) {
throw new BadRequestException(
"At least one party must be specified for resend request",
);
}
// Validate no existing expert decision
if (request.expert?.decision) {
throw new ForbiddenException(
"Cannot request resend after expert decision has been made.",
);
}
const now = new Date();
// Build resend requests for parties
const partyResendRequests = resendDto.parties.map((party) => ({
partyId: new Types.ObjectId(String(party.partyId)),
requestedItems: party.requestedItems,
description: party.description || "",
requestedAt: now,
completed: false,
}));
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_DOCUMENT_RESEND",
"workflow.nextStep": "WAITING_FOR_GUILT_DECISION",
"expert.resend": {
parties: partyResendRequests,
requestedAt: now,
requestedByExpertId: new Types.ObjectId(actorId),
},
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with resend request",
);
}
// TODO: Send notifications to parties (SMS/Push) about required documents
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"resendRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to request document resend",
);
}
}
/**
* V2: Submit expert reply for blame case (blameCases collection).
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
*/
async replyRequestV2(
requestId: string,
reply: SubmitReplyDto,
actorId: string,
): Promise<{ requestId: string; status: string }> {
try {
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
"This request already has an expert decision.",
);
}
// Check if request is locked
if (!request.workflow?.locked) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",
);
}
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
const lockedAt = request.workflow?.lockedAt;
const isLockedByCurrentActor = lockedByActorId === actorId;
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
isLockExpired = Date.now() > lockExpiryTime;
}
// Handle different lock scenarios
if (!isLockedByCurrentActor) {
// Request is locked by another expert
if (isLockExpired) {
throw new ForbiddenException(
"You must lock the request first before submitting a reply.",
);
} else {
throw new ForbiddenException(
"This request is currently locked by another expert.",
);
}
}
// Current actor is the lock owner
if (isLockExpired) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again before submitting.",
);
}
const now = new Date();
// Build expert decision with fields
const decisionPayload: any = {
guiltyPartyId: new Types.ObjectId(String(reply.guiltyUserId)),
description: reply.description,
decidedAt: now,
decidedByExpertId: new Types.ObjectId(actorId),
fields: {
accidentWay: {
id: reply.fields.accidentWay.id,
label: reply.fields.accidentWay.label,
},
accidentReason: {
id: reply.fields.accidentReason.id,
label: reply.fields.accidentReason.label,
fanavaran: reply.fields.accidentReason.fanavaran,
},
accidentType: {
id: reply.fields.accidentType.id,
label: reply.fields.accidentType.label,
},
},
};
const updatePayload = {
$set: {
"workflow.locked": false,
"workflow.currentStep": "WAITING_FOR_SIGNATURES",
"workflow.nextStep": null,
"expert.decision": decisionPayload,
status: CaseStatus.WAITING_FOR_SIGNATURES,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
},
};
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
requestId,
updatePayload,
);
if (!updateResult) {
throw new InternalServerErrorException(
"Failed to update request with expert reply",
);
}
return {
requestId: String(request._id),
status: CaseStatus.WAITING_FOR_SIGNATURES,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"replyRequestV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply",
);
}
}
private async updateDamageExpertStats( private async updateDamageExpertStats(
expertId: string, expertId: string,
requestId: string, requestId: string,

View File

@@ -0,0 +1,104 @@
import {
Body,
Controller,
Get,
HttpException,
InternalServerErrorException,
Param,
Put,
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertBlameService } from "./expert-blame.service";
import { SubmitReplyDto } from "./dto/reply.dto";
import { ResendRequestDto } from "./dto/resend.dto";
@ApiTags("expert-blame-panel (v2)")
@Controller("v2/expert-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT)
export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) {}
@Get()
async findAll(@CurrentUser() actor: any) {
try {
return await this.expertBlameService.findAllV2(actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to list blame cases",
);
}
}
@Get(":id")
@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);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details",
);
}
}
@Put("lock/:id")
@ApiParam({ name: "id", description: "Blame case request id" })
async lockRequest(@Param("id") id: string, @CurrentUser() actor: any) {
try {
return await this.expertBlameService.lockRequestV2(id, actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to lock blame case",
);
}
}
@Put("reply/resend/:id")
@ApiParam({ name: "id", description: "Blame case request id" })
@ApiBody({ type: ResendRequestDto })
async resendRequest(
@Param("id") id: string,
@Body() body: ResendRequestDto,
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error
? error.message
: "Failed to request document resend",
);
}
}
@Put("reply/submit/:id")
@ApiParam({ name: "id", description: "Blame case request id" })
@ApiBody({ type: SubmitReplyDto })
async submitReply(
@Param("id") id: string,
@Body() body: SubmitReplyDto,
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit expert reply",
);
}
}
}

View File

@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ClaimDetailV2ResponseDto {
@ApiProperty()
claimRequestId: string;
@ApiProperty()
publicId: string;
@ApiProperty()
status: string;
@ApiProperty()
claimStatus: string;
@ApiProperty()
currentStep: string;
@ApiPropertyOptional()
nextStep?: string;
@ApiProperty({ description: 'Whether claim is locked' })
locked: boolean;
@ApiPropertyOptional()
lockedBy?: {
actorId: string;
actorName: string;
lockedAt: string;
};
@ApiPropertyOptional()
owner?: {
userId: string;
fullName?: string;
};
@ApiPropertyOptional()
vehicle?: {
carName?: string;
carModel?: string;
carType?: string;
plate?: any;
};
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
blameRequestId?: string;
@ApiPropertyOptional({ description: 'Blame request number', example: 'BL12345' })
blameRequestNo?: string;
@ApiPropertyOptional({ description: 'Selected outer damaged parts' })
selectedParts?: string[];
@ApiPropertyOptional({ description: 'Selected other damaged parts' })
otherParts?: string[];
@ApiPropertyOptional({ description: 'Required documents status' })
requiredDocuments?: Record<string, {
uploaded: boolean;
fileId?: string;
fileName?: string;
filePath?: string;
}>;
@ApiPropertyOptional({ description: 'Car angles captured' })
carAngles?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiProperty()
createdAt: string;
@ApiProperty()
updatedAt: string;
}

View File

@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID' })
claimRequestId: string;
@ApiProperty({ description: 'Public ID shared across blame+claim', example: 'A14235' })
publicId: string;
@ApiProperty({ description: 'Overall case status', example: 'WAITING_FOR_DAMAGE_EXPERT' })
status: string;
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
currentStep: string;
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
locked: boolean;
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
lockedBy?: {
actorId: string;
actorName: string;
};
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
vehicle?: {
carName?: string;
carModel?: string;
carType?: string;
};
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
createdAt: string;
}
export class GetClaimListV2ResponseDto {
@ApiProperty({ type: [ClaimListItemV2Dto] })
list: ClaimListItemV2Dto[];
@ApiProperty({ description: 'Total count' })
total: number;
}

View File

@@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNotEmpty,
IsArray,
IsBoolean,
IsOptional,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CarPartDamageV2Dto {
@ApiProperty({ example: 'left' })
@IsString()
side: string;
@ApiProperty({ example: 'front_door' })
@IsString()
part: string;
}
export class PartPricingV2Dto {
@ApiProperty({ example: 'part-001' })
@IsString()
@IsNotEmpty()
partId: string;
@ApiProperty({ type: CarPartDamageV2Dto })
@ValidateNested()
@Type(() => CarPartDamageV2Dto)
carPartDamage: CarPartDamageV2Dto;
@ApiProperty({ example: 'Minor' })
@IsString()
typeOfDamage: string;
@ApiProperty({ example: '5000000' })
@IsString()
price: string;
@ApiProperty({ example: '2000000' })
@IsString()
salary: string;
@ApiProperty({ example: '7000000' })
@IsString()
totalPayment: string;
@ApiPropertyOptional({ example: '500000' })
@IsOptional()
@IsString()
daghi?: string;
@ApiProperty({ example: false })
@IsBoolean()
factorNeeded: boolean;
}
export class SubmitExpertReplyV2Dto {
@ApiProperty({ example: 'Front door and hood have severe paint damage' })
@IsString()
@IsNotEmpty()
description: string;
@ApiProperty({ type: [PartPricingV2Dto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[];
}

View File

@@ -15,6 +15,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { ExpertClaimController } from "./expert-claim.controller"; import { ExpertClaimController } from "./expert-claim.controller";
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
import { ExpertClaimService } from "./expert-claim.service"; import { ExpertClaimService } from "./expert-claim.service";
@Module({ @Module({
@@ -33,7 +34,7 @@ import { ExpertClaimService } from "./expert-claim.service";
UsersModule, UsersModule,
ClientModule, ClientModule,
], ],
controllers: [ExpertClaimController], controllers: [ExpertClaimController, ExpertClaimV2Controller],
providers: [ExpertClaimService, ClaimFactorsImageDbService], providers: [ExpertClaimService, ClaimFactorsImageDbService],
exports: [ClaimFactorsImageDbService, ExpertClaimService], exports: [ClaimFactorsImageDbService, ExpertClaimService],
}) })

View File

@@ -29,6 +29,12 @@ import { UserType } from "src/Types&Enums/userType.enum";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto"; import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto"; 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 { 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";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto"; import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service"; import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service"; import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
@@ -146,6 +152,7 @@ export class ExpertClaimService {
private readonly blameVideoDbService: BlameVideoDbService, private readonly blameVideoDbService: BlameVideoDbService,
private readonly claimVideoCaptureDbService: VideoCaptureDbService, private readonly claimVideoCaptureDbService: VideoCaptureDbService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly sandHubService: SandHubService, private readonly sandHubService: SandHubService,
private readonly damageExpertDbService: DamageExpertDbService, private readonly damageExpertDbService: DamageExpertDbService,
private readonly clientDbService: ClientDbService, private readonly clientDbService: ClientDbService,
@@ -183,20 +190,45 @@ export class ExpertClaimService {
} }
async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> { async getClaimRequestsListForExpert(actor): Promise<ClaimListDtoRs> {
console.log(actor);
// const requests = await this.claimRequestManagementDbService.findAllByStatus(
// {
// claimStatus: {
// $in: [
// ReqClaimStatus.UnChecked,
// ReqClaimStatus.ReviewRequest,
// ReqClaimStatus.CheckAgain,
// ReqClaimStatus.CloseRequest,
// ReqClaimStatus.InPersonVisit,
// ReqClaimStatus.CheckedRequest,
// ReqClaimStatus.PendingFactorValidation,
// ],
// },
// "damageExpertReply.actorDetail.actorId":actor.sub
// },
// );
const requests = await this.claimRequestManagementDbService.findAllByStatus( const requests = await this.claimRequestManagementDbService.findAllByStatus(
{ {
claimStatus: { $or: [
$in: [ {
ReqClaimStatus.UnChecked, claimStatus: ReqClaimStatus.UnChecked,
ReqClaimStatus.ReviewRequest, },
ReqClaimStatus.CheckAgain, {
ReqClaimStatus.CloseRequest, claimStatus: {
ReqClaimStatus.InPersonVisit, $in: [
ReqClaimStatus.CheckedRequest, ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation, ReqClaimStatus.CheckAgain,
], ReqClaimStatus.CloseRequest,
}, ReqClaimStatus.InPersonVisit,
}, ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
],
},
"damageExpertReply.actorDetail.actorId": actor.sub,
},
],
}
); );
const filteredRequests = []; const filteredRequests = [];
@@ -1526,4 +1558,415 @@ export class ExpertClaimService {
this.logger.error(error.response?.responseCode, error.response); this.logger.error(error.response?.responseCode, error.response);
throw new HttpException(error.response, error.claimStatus); throw new HttpException(error.response, error.claimStatus);
} }
/**
* V2: Lock a claim request for expert review.
*
* Validations:
* - Claim must exist
* - Status must be WAITING_FOR_DAMAGE_EXPERT
* - Must not already be locked by another expert
*
* On success:
* - Sets workflow.locked = true, workflow.lockedBy = actor
* - Sets status = EXPERT_REVIEWING
* - Sets claimStatus = UNDER_REVIEW
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
*/
async lockClaimRequestV2(claimRequestId: string, actor: any) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new BadRequestException(
`Claim is not available for locking. Current status: ${claim.status}`,
);
}
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actor.sub
) {
throw new BadRequestException(
`Claim is already locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
// Already locked by this same expert — idempotent
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() === actor.sub
) {
return { claimRequestId, locked: true, message: 'Already locked by you' };
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
'workflow.locked': true,
'workflow.lockedAt': new Date(),
'workflow.lockedBy': {
actorId: new Types.ObjectId(actor.sub),
actorName: actor.fullName,
actorRole: 'damage_expert',
},
'workflow.currentStep': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
$push: {
history: {
event: 'CLAIM_LOCKED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Claim locked by damage expert ${actor.fullName}`,
},
},
});
return {
claimRequestId,
locked: true,
status: ClaimCaseStatus.EXPERT_REVIEWING,
claimStatus: ClaimStatus.UNDER_REVIEW,
currentStep: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
};
}
/**
* V2: Submit damage expert reply for a claim.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert (workflow.lockedBy.actorId === actor.sub)
* - Must be in EXPERT_REVIEWING status
* - Total payment across all parts must not exceed 30,000,000
*
* On success:
* - Stores reply in evaluation.damageExpertReply
* - Unlocks the workflow
* - If any part has factorNeeded=true → status = WAITING_FOR_INSURER_APPROVAL, step = EXPERT_COST_EVALUATION
* - Otherwise → status = WAITING_FOR_INSURER_APPROVAL, step = INSURER_REVIEW, claimStatus = APPROVED
*/
async submitExpertReplyV2(
claimRequestId: string,
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in a reviewable state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before submitting a reply',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
// Price cap validation
const PRICE_CAP = 30_000_000;
let totalPrice = 0;
for (const part of reply.parts || []) {
const parsed = this.parsePersianNumber(String(part.totalPayment ?? '0'));
if (!isNaN(parsed)) totalPrice += parsed;
}
if (totalPrice > PRICE_CAP) {
throw new BadRequestException({
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
error: 'PRICE_CAP_ERROR',
totalPrice,
priceCap: PRICE_CAP,
});
}
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
const replyPayload = {
description: reply.description,
parts: reply.parts,
submittedAt: new Date(),
actorDetail: {
actorId: actor.sub,
actorName: actor.fullName,
},
};
const nextStep = needsFactorUpload
? ClaimWorkflowStep.EXPERT_COST_EVALUATION
: ClaimWorkflowStep.INSURER_REVIEW;
const nextCaseStatus = ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL;
const nextClaimStatus = needsFactorUpload
? ClaimStatus.NEEDS_REVISION
: ClaimStatus.APPROVED;
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
status: nextCaseStatus,
claimStatus: nextClaimStatus,
'workflow.locked': false,
'workflow.currentStep': nextStep,
'workflow.nextStep': needsFactorUpload
? ClaimWorkflowStep.INSURER_REVIEW
: ClaimWorkflowStep.CLAIM_COMPLETED,
'evaluation.damageExpertReply': replyPayload,
$push: {
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
history: {
event: 'EXPERT_REPLY_SUBMITTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
},
},
});
return {
claimRequestId,
status: nextCaseStatus,
claimStatus: nextClaimStatus,
currentStep: nextStep,
factorNeeded: needsFactorUpload,
};
}
/**
* V2: Expert marks claim for in-person visit.
*
* This is used when the expert decides the user must attend in person
* before a final assessment can be made.
*
* Validations:
* - Claim must exist
* - Must be locked by this expert
* - Status must be EXPERT_REVIEWING
*
* On success:
* - Sets status = EXPERT_REVIEWING (remains), claimStatus = NEEDS_REVISION
* - Sets evaluation.visitLocation (optional note)
* - Records history event IN_PERSON_VISIT_REQUESTED
* - Unlocks the workflow so user can act
*/
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
'You must lock the claim before requesting an in-person visit',
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException('This claim is locked by another expert');
}
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION,
'workflow.locked': false,
...(note ? { 'evaluation.visitLocation': note } : {}),
$push: {
history: {
event: 'IN_PERSON_VISIT_REQUESTED',
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: note || 'Expert requested in-person visit',
},
},
});
return {
claimRequestId,
status: claim.status,
claimStatus: ClaimStatus.NEEDS_REVISION,
message: 'In-person visit requested. User will be notified.',
};
}
/**
* 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)
*/
async getClaimListV2(actorId: string): Promise<GetClaimListV2ResponseDto> {
const claims = await this.claimCaseDbService.find({
$or: [
// Available claims: waiting for expert, not locked
{
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
'workflow.locked': { $ne: true },
},
// This expert's own locked/in-progress claims
{
'workflow.locked': true,
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
},
],
});
const list = (claims as any[]).map((c) => ({
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,
})) as ClaimListItemV2Dto[];
return { list, total: list.length };
}
/**
* 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
*/
async getClaimDetailV2(
claimRequestId: string,
actorId: string,
): Promise<ClaimDetailV2ResponseDto> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
throw new ForbiddenException(
`This claim is not available for expert review. Current status: ${claim.status}`,
);
}
// If locked by someone else, deny access
if (
claim.workflow?.locked &&
claim.workflow.lockedBy?.actorId?.toString() !== actorId
) {
throw new ForbiddenException(
`This claim is locked by another expert: ${claim.workflow.lockedBy?.actorName}`,
);
}
const hasCapture = (data: any, key: string) =>
data && (data instanceof Map ? data.get(key) : data[key]);
// Build requiredDocuments map
const requiredDocs = claim.requiredDocuments as any;
const requiredDocumentsStatus: Record<string, any> = {};
if (requiredDocs) {
const keys =
requiredDocs instanceof Map
? Array.from(requiredDocs.keys())
: Object.keys(requiredDocs);
for (const k of keys as string[]) {
const doc = requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
requiredDocumentsStatus[k] = {
uploaded: !!doc?.uploaded,
fileId: doc?.fileId?.toString(),
fileName: doc?.fileName,
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
};
}
}
// Build car angles map
const carAnglesData = claim.media?.carAngles as any;
const carAngles: Record<string, { captured: boolean; url?: string }> = {};
for (const k of ['front', 'back', 'left', 'right']) {
const cap = hasCapture(carAnglesData, k);
carAngles[k] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
// Build damaged parts map
const damagedPartsData = claim.media?.damagedParts as any;
const damagedParts: Record<string, { captured: boolean; url?: string }> = {};
for (const p of claim.damage?.selectedParts || []) {
const cap = hasCapture(damagedPartsData, p);
damagedParts[p] = {
captured: !!cap,
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
};
}
return {
claimRequestId: claim._id.toString(),
publicId: claim.publicId,
status: claim.status,
claimStatus: claim.claimStatus || 'PENDING',
currentStep: claim.workflow?.currentStep || '',
nextStep: claim.workflow?.nextStep,
locked: claim.workflow?.locked || false,
lockedBy: claim.workflow?.lockedBy
? {
actorId: claim.workflow.lockedBy.actorId?.toString(),
actorName: claim.workflow.lockedBy.actorName,
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
}
: undefined,
owner: claim.owner
? {
userId: claim.owner.userId?.toString(),
fullName: claim.owner.fullName,
}
: undefined,
vehicle: claim.vehicle,
blameRequestId: claim.blameRequestId?.toString(),
blameRequestNo: claim.blameRequestNo,
selectedParts: claim.damage?.selectedParts,
otherParts: claim.damage?.otherParts,
requiredDocuments:
Object.keys(requiredDocumentsStatus).length > 0
? requiredDocumentsStatus
: undefined,
carAngles,
damagedParts,
createdAt: (claim as any).createdAt,
updatedAt: (claim as any).updatedAt,
};
}
} }

View File

@@ -0,0 +1,110 @@
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiPropertyOptional,
ApiTags,
} from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertClaimService } from "./expert-claim.service";
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@IsOptional()
@IsString()
note?: string;
}
@ApiTags("expert-claim-panel (v2)")
@Controller("v2/expert-claim")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.DAMAGE_EXPERT)
export class ExpertClaimV2Controller {
constructor(private readonly expertClaimService: ExpertClaimService) {}
@Get("requests")
@ApiOperation({
summary: "List available claim requests for damage expert",
description:
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.",
})
async getClaimListV2(@CurrentUser() actor) {
return await this.expertClaimService.getClaimListV2(actor.sub);
}
@Get("request/:claimRequestId")
@ApiOperation({
summary: "Get claim request detail for damage expert",
description:
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.",
})
@ApiParam({ name: "claimRequestId" })
async getClaimDetailV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.getClaimDetailV2(
claimRequestId,
actor.sub,
);
}
@Put("lock/:claimRequestId")
@ApiOperation({
summary: "Lock a claim request for review",
description:
"Claim must have status WAITING_FOR_DAMAGE_EXPERT. Locking sets status to EXPERT_REVIEWING and claimStatus to UNDER_REVIEW. Only one expert can lock a claim at a time.",
})
@ApiParam({ name: "claimRequestId" })
async lockClaimRequestV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.lockClaimRequestV2(claimRequestId, actor);
}
@Put("reply/submit/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage assessment reply",
description:
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SubmitExpertReplyV2Dto })
async submitExpertReplyV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SubmitExpertReplyV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
}
@Patch(":claimRequestId/visit")
@ApiOperation({
summary: "Request in-person visit for claim",
description:
"Expert decides the user must come in person. Claim must be locked by this expert. Sets claimStatus to NEEDS_REVISION and unlocks the claim so the user is informed.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: InPersonVisitV2Dto, required: false })
async requestInPersonVisitV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: InPersonVisitV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.requestInPersonVisitV2(
claimRequestId,
actor,
body?.note,
);
}
}

View File

@@ -0,0 +1,59 @@
/**
* Converts an ISO date string or Date to Jalali (Persian) date and time.
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
* @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37']
*/
export function toJalaliDateAndTime(
input: string | Date,
): [string, string] {
const d = new Date(input);
const dateStr = toJalaliDate(d);
const timeStr = toJalaliTime(d);
return [dateStr, timeStr];
}
/**
* Converts a Date to Jalali date string (e.g. 1404/11/24).
*/
export function toJalaliDate(d: Date): string {
const persianDate = d.toLocaleDateString("fa-IR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return persianDigitsToEnglish(persianDate, "/");
}
/**
* Converts a Date to 24h time string (e.g. 13:37).
*/
export function toJalaliTime(d: Date): string {
const hours = d.getHours().toString().padStart(2, "0");
const minutes = d.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
const PERSIAN_TO_ENGLISH: Record<string, string> = {
"۰": "0",
"۱": "1",
"۲": "2",
"۳": "3",
"۴": "4",
"۵": "5",
"۶": "6",
"۷": "7",
"۸": "8",
"۹": "9",
};
function persianDigitsToEnglish(str: string, separator: string): string {
return str
.split(separator)
.map((part) =>
part
.split("")
.map((char) => PERSIAN_TO_ENGLISH[char] ?? char)
.join(""),
)
.join("/");
}

View File

@@ -30,8 +30,8 @@ async function bootstrap() {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle("yara724-backend") .setTitle("yara724-backend")
.setVersion("1.0.0") .setVersion("1.0.0")
.addServer(process.env.BASE_URL + "/api")
.addServer(process.env.URL) .addServer(process.env.URL)
.addServer(process.env.BASE_URL + "/api")
.addServer("http://192.168.20.249:9001") .addServer("http://192.168.20.249:9001")
.addServer("http://192.168.20.170:9001") .addServer("http://192.168.20.170:9001")
.addServer("http://localhost:9001") .addServer("http://localhost:9001")

View File

@@ -1,10 +1,6 @@
import { ApiProperty, ApiSchema } from "@nestjs/swagger"; import { ApiProperty } from "@nestjs/swagger";
import { IsDateString } from "class-validator"; import { IsDateString } from "class-validator";
@ApiSchema({
name: "Update user profile",
description: "Users updates their profile via this API.",
})
export class UpdateUserProfileDtoRq { export class UpdateUserProfileDtoRq {
// @ApiProperty({}) // @ApiProperty({})
// mobile: string; // mobile: string;

View File

@@ -7,6 +7,7 @@ import {
RoadCondition, RoadCondition,
LightCondition, LightCondition,
} from "src/Types&Enums/blame-request-management/accident-conditions.enum"; } from "src/Types&Enums/blame-request-management/accident-conditions.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
export class InitialFormDto { export class InitialFormDto {
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: false })
@@ -135,6 +136,28 @@ export class CreateRequestDto {
type?: "THIRD_PARTY" | "CAR_BODY"; type?: "THIRD_PARTY" | "CAR_BODY";
} }
/**
* V2 create blame request (new model `BlameRequest`).
*/
export class CreateBlameRequestDtoV2 {
@ApiProperty({ enum: BlameRequestType })
type: BlameRequestType;
}
/**
* V2 step: FIRST_BLAME_CONFESSION
*/
export class BlameConfessionDtoV2 {
@ApiProperty({ required: false, default: false })
expertOpinion?: boolean;
@ApiProperty({ required: false, default: false })
imDamaged?: boolean;
@ApiProperty({ required: true, default: false })
imGuilty?: boolean;
}
// export class DocsOfThisFile { // export class DocsOfThisFile {
// @ApiProperty() // @ApiProperty()
// firstPartyFile?: FirstPartyFileDto; // firstPartyFile?: FirstPartyFileDto;

View File

@@ -0,0 +1,46 @@
import { ApiProperty } from "@nestjs/swagger";
export class UserResendResponseDto {
@ApiProperty({
example: "67fa10c259e15f231a2d1aae",
description: "Request ID",
})
requestId: string;
@ApiProperty({
example: ["drivingLicense", "carCertificate"],
description: "List of items the current user needs to upload",
})
requestedItems: string[];
@ApiProperty({
example: "Please resend your driving license and car certificate with better quality",
description: "Expert's message explaining why resend is needed",
})
description: string;
@ApiProperty({
example: false,
description: "Whether the user has completed uploading all requested items",
})
completed: boolean;
}
export class UserResendUploadResponseDto {
@ApiProperty({
example: "Documents uploaded successfully",
})
message: string;
@ApiProperty({
example: ["drivingLicense", "carCertificate"],
description: "List of items that were uploaded",
})
uploadedItems: string[];
@ApiProperty({
example: true,
description: "Whether all requested items have been uploaded",
})
allItemsCompleted: boolean;
}

View File

@@ -0,0 +1,27 @@
import { ApiProperty } from "@nestjs/swagger";
export class UserSignatureResponseDto {
@ApiProperty({
example: "67fa10c259e15f231a2d1aae",
description: "Request ID",
})
requestId: string;
@ApiProperty({
example: true,
description: "Whether the user accepted the expert decision",
})
accepted: boolean;
@ApiProperty({
example: "COMPLETED",
description: "Updated request status",
})
status: string;
@ApiProperty({
example: "Both parties have signed. Case completed.",
description: "Result message",
})
message: string;
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model, Types } from "mongoose";
import {
BlameRequest,
BlameRequestDocument,
} from "src/request-management/entities/schema/blame-cases.schema";
@Injectable()
export class BlameRequestDbService {
constructor(
@InjectModel(BlameRequest.name)
private readonly blameRequestModel: Model<BlameRequestDocument>,
) {}
async create(payload: Partial<BlameRequest>): Promise<BlameRequestDocument> {
return this.blameRequestModel.create(payload);
}
async findOne(
filter: FilterQuery<BlameRequest>,
): Promise<BlameRequestDocument | null> {
return this.blameRequestModel.findOne(filter);
}
async findById(id: string | Types.ObjectId): Promise<BlameRequestDocument | null> {
return this.blameRequestModel.findById(id);
}
/** Find by id excluding history (e.g. for expert detail view). Returns lean plain object. */
async findByIdWithoutHistory(
id: string | Types.ObjectId,
): Promise<Record<string, unknown> | null> {
const doc = await this.blameRequestModel
.findById(id)
.select("-history")
.lean()
.exec();
return doc as Record<string, unknown> | null;
}
async findByIdAndUpdate(
id: string | Types.ObjectId,
update: any,
): Promise<BlameRequestDocument | null> {
return this.blameRequestModel.findByIdAndUpdate(id, update, { new: true });
}
/** Atomic find and update with filter conditions (e.g. for locking). */
async findOneAndUpdate(
filter: FilterQuery<BlameRequest>,
update: any,
options?: { new?: boolean },
): Promise<BlameRequestDocument | null> {
return this.blameRequestModel.findOneAndUpdate(filter, update, {
new: options?.new ?? true,
});
}
async find(
filter: FilterQuery<BlameRequest>,
options?: { lean?: boolean; select?: string },
): Promise<BlameRequestDocument[] | Record<string, unknown>[]> {
if (options?.lean) {
const query = options?.select
? this.blameRequestModel.find(filter).select(options.select).lean()
: this.blameRequestModel.find(filter).lean();
return query.exec() as Promise<Record<string, unknown>[]>;
}
const query = options?.select
? this.blameRequestModel.find(filter).select(options.select)
: this.blameRequestModel.find(filter);
return query.exec() as Promise<BlameRequestDocument[]>;
}
}

View File

@@ -0,0 +1,54 @@
//! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
@Schema({ _id: false })
export class ReferenceItem {
@Prop() id: string;
@Prop() label: string;
}
export const ReferenceItemSchema = SchemaFactory.createForClass(ReferenceItem);
@Schema({ _id: false })
export class AccidentReason extends ReferenceItem {
@Prop() fanavaran: number;
}
export const AccidentReasonSchema = SchemaFactory.createForClass(AccidentReason);
@Schema({ _id: false })
export class AccidentClassification {
@Prop({ type: ReferenceItemSchema })
accidentWay: ReferenceItem;
@Prop({ type: ReferenceItemSchema })
accidentType: ReferenceItem;
@Prop({ type: AccidentReasonSchema })
accidentReason: AccidentReason;
}
export const AccidentClassificationSchema =
SchemaFactory.createForClass(AccidentClassification);
@Schema({ _id: false })
export class Location {
@Prop() lat: number;
@Prop() lon: number;
}
export const LocationSchema = SchemaFactory.createForClass(Location);
@Schema({ _id: false })
export class AccidentInfo {
@Prop({ type: Date })
date?: Date;
@Prop()
time?: string;
@Prop({ type: LocationSchema })
location?: Location;
@Prop({ type: AccidentClassificationSchema })
classification?: AccidentClassification;
}
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);

View File

@@ -0,0 +1,69 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { Party, PartySchema } from "./partyRole.enum";
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
import { Workflow, WorkflowSchema } from "./workflow.type";
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
@Schema({ _id: false })
export class BlameRequestSnapshot {
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties?: Party[];
}
export const BlameRequestSnapshotSchema =
SchemaFactory.createForClass(BlameRequestSnapshot);
@Schema({ collection: "blameCases", timestamps: true , id:true })
export class BlameRequest {
@Prop({ required: true, unique: true, index: true, trim: true })
requestNo: string;
/**
* Human-friendly shared id across blame+claim (e.g. A14235).
* Generated once at creation time and reused by claim.
*/
@Prop({ required: true, unique: true, index: true, trim: true })
publicId: string;
@Prop({ required: true, type: String, enum: BlameRequestType })
type: BlameRequestType;
@Prop({ required: true, type: String, enum: CaseStatus, default: CaseStatus.OPEN })
status: CaseStatus;
@Prop({ type: String, enum: BlameStatus, default: BlameStatus.UNKNOWN })
blameStatus: BlameStatus;
@Prop({ type: WorkflowSchema })
workflow?: Workflow;
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties: Party[];
@Prop({ type: ExpertSectionSchema })
expert?: ExpertSection;
@Prop({ type: [HistoryEventSchema], default: [] })
history: HistoryEvent[];
/**
* Optional snapshot structure (kept for future use / read-optimized copies).
* Source of truth remains the top-level fields of this document.
*/
@Prop({ type: BlameRequestSnapshotSchema, required: false })
snapshot?: BlameRequestSnapshot;
}
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest);

View File

@@ -0,0 +1,142 @@
//! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
@Schema({ _id: false })
export class ExpertLink {
@Prop({ required: true })
token: string;
@Prop({ type: Date })
expiresAt: Date;
@Prop({ default: false })
isUsed: boolean;
@Prop({ default: 0 })
openCount: number;
@Prop()
sentTo?: string;
@Prop()
lastOpenedAt?: Date;
}
export const ExpertLinkSchema = SchemaFactory.createForClass(ExpertLink);
export enum FilledBy {
CUSTOMER = "CUSTOMER",
EXPERT = "EXPERT"
}
@Schema({ _id: false })
export class ExpertDecision {
@Prop({ type: Types.ObjectId })
guiltyPartyId: Types.ObjectId;
@Prop()
description: string;
@Prop({ type: Date, default: Date.now })
decidedAt: Date;
@Prop({ type: Types.ObjectId })
decidedByExpertId?: Types.ObjectId;
@Prop({ type: Object })
fields?: {
accidentWay: { id: string; label: string };
accidentReason: { id: string; label: string; fanavaran: number };
accidentType: { id: string; label: string };
};
}
export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision);
@Schema({ _id: false })
export class PartyResendRequest {
@Prop({ type: Types.ObjectId, required: true })
partyId: Types.ObjectId;
@Prop({ type: [String], default: [] })
requestedItems: string[]; // Array of ResendItemType values
@Prop()
description?: string;
@Prop({ type: Date, default: Date.now })
requestedAt: Date;
@Prop({ type: Boolean, default: false })
completed?: boolean;
@Prop({ type: Date })
completedAt?: Date;
}
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
@Schema({ _id: false })
export class ExpertResend {
@Prop({ type: [PartyResendRequestSchema], default: [] })
parties: PartyResendRequest[];
@Prop({ type: Date })
requestedAt?: Date;
@Prop({ type: Types.ObjectId })
requestedByExpertId?: Types.ObjectId;
}
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);
@Schema({ _id: false })
export class FileRating {
@Prop({ min: 0, max: 5 })
collisionMethodAccuracy: number;
@Prop({ min: 0, max: 5 })
evaluationTimeliness: number;
@Prop({ min: 0, max: 5 })
accidentCauseAccuracy: number;
@Prop({ min: 0, max: 5 })
guiltyVehicleIdentification: number;
}
export const FileRatingSchema = SchemaFactory.createForClass(FileRating);
export enum CreationMethod {
NORMAL = "NORMAL",
LINK = "LINK",
IN_PERSON = "IN_PERSON"
}
@Schema({ _id: false })
export class ExpertSection {
@Prop({ type: Types.ObjectId })
assignedExpertId?: Types.ObjectId;
@Prop({ enum: CreationMethod })
creationMethod: CreationMethod;
@Prop({ enum: FilledBy })
filledBy?: FilledBy;
@Prop({ enum: ExpertDecisionSchema })
decision?: ExpertDecision;
@Prop({ type: ExpertResendSchema })
resend?: ExpertResend;
@Prop({ type: FileRatingSchema })
rating?: FileRating;
@Prop({ type: ExpertLinkSchema })
link?: ExpertLink;
}
export const ExpertSectionSchema = SchemaFactory.createForClass(ExpertSection);

View File

@@ -0,0 +1,32 @@
//! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "mongoose";
@Schema({ _id: false })
export class HistoryActor {
@Prop({ type: Types.ObjectId })
actorId?: Types.ObjectId;
@Prop({ type: String })
actorName?: string;
@Prop({ type: String })
actorType?: string;
}
export const HistoryActorSchema = SchemaFactory.createForClass(HistoryActor);
@Schema({ _id: false })
export class HistoryEvent {
@Prop({ type: String, required: true })
type: string;
@Prop({ type: HistoryActorSchema })
actor?: HistoryActor;
@Prop({ type: Date, default: () => new Date() })
timestamp: Date;
@Prop({ type: MongooseSchema.Types.Mixed })
metadata?: any;
}
export const HistoryEventSchema = SchemaFactory.createForClass(HistoryEvent);

View File

@@ -0,0 +1,154 @@
//! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Schema as MongooseSchema, Types } from "mongoose";
import { Location, LocationSchema } from "./accidentInformation.type";
export enum PartyRole {
FIRST = "FIRST",
SECOND = "SECOND",
}
@Schema({ _id: false })
export class Person {
@Prop({ type: Types.ObjectId })
userId?: Types.ObjectId;
@Prop() fullName?: string;
@Prop() phoneNumber?: string;
@Prop({ type: Types.ObjectId }) clientId?: Types.ObjectId;
@Prop() birthday?: string;
// ---- FIRST_INITIAL_FORM (step-manager driven) ----
@Prop() nationalCodeOfInsurer?: string;
@Prop() nationalCodeOfDriver?: string;
@Prop() insurerLicense?: string;
@Prop() driverLicense?: string;
@Prop({ type: Boolean })
driverIsInsurer?: boolean;
@Prop({ type: Boolean })
isNewCar?: boolean;
/**
* Mirrors existing DTO `userNoCertificate` (true means user has NO certificate).
*/
@Prop({ type: Boolean })
userNoCertificate?: boolean;
@Prop({ type: Number })
insurerBirthday?: number;
@Prop({ type: String })
driverBirthday?: string | null;
}
export const PersonSchema = SchemaFactory.createForClass(Person);
@Schema({ _id: false })
export class Vehicle {
@Prop() plateId?: string;
@Prop() name?: string;
@Prop() model?: string;
@Prop() type?: string;
@Prop() isNew?: boolean;
/**
* Full external inquiry payload (Tejarat/SandHub) stored as-is
* so we never lose fields that are not mapped yet.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
inquiry?: any;
}
export const VehicleSchema = SchemaFactory.createForClass(Vehicle);
@Schema({ _id: false })
export class Insurance {
@Prop() policyNumber?: string;
@Prop() company?: string;
@Prop() startDate?: string;
@Prop() endDate?: string;
@Prop() financialCeiling?: string;
@Prop({ type: [String] })
coverages?: string[];
}
export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
@Schema({ _id: false })
export class PartyStatement {
@Prop({ default: false })
acceptsExpertOpinion?: boolean;
@Prop({ default: false })
claimsDamage?: boolean;
@Prop({ default: false })
admitsGuilt?: boolean;
@Prop({ type: String })
description?: string;
}
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
@Schema({ _id: false })
export class EvidenceBundle {
@Prop({ type: [String] })
images?: string[];
@Prop({ type: [String] })
voices?: string[];
@Prop() videoId?: string;
}
export const EvidenceBundleSchema = SchemaFactory.createForClass(EvidenceBundle);
@Schema({ _id: false })
export class Signature {
@Prop() fileId: string;
@Prop() fileName: string;
@Prop() fileUrl: string;
}
export const SignatureSchema = SchemaFactory.createForClass(Signature);
@Schema({ _id: false })
export class PartyConfirmation {
@Prop() partyRole: PartyRole;
@Prop() accepted: boolean;
@Prop({ type: SignatureSchema })
signature?: Signature;
}
export const PartyConfirmationSchema =
SchemaFactory.createForClass(PartyConfirmation);
@Schema({ _id: false })
export class Party {
@Prop({ enum: PartyRole })
role: PartyRole;
@Prop({ type: PersonSchema })
person: Person;
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/
@Prop({ type: LocationSchema })
location?: Location;
@Prop({ type: VehicleSchema })
vehicle?: Vehicle;
@Prop({ type: InsuranceSchema })
insurance?: Insurance;
@Prop({ type: PartyStatementSchema })
statement?: PartyStatement;
@Prop({ type: EvidenceBundleSchema })
evidence?: EvidenceBundle;
@Prop({ type: PartyConfirmationSchema })
confirmation?: PartyConfirmation;
}
export const PartySchema = SchemaFactory.createForClass(Party);

View File

@@ -368,6 +368,15 @@ export class RequestManagementModel {
@Prop({ type: String, required: true, index: { unique: true } }) @Prop({ type: String, required: true, index: { unique: true } })
requestNumber: string; requestNumber: string;
/**
* Human-friendly shared id across blame+claim (e.g. A14235).
* Generated once at creation time and reused by claim.
*
* NOTE: `sparse` keeps existing documents without this field valid.
*/
@Prop({ type: String, unique: true, index: true, sparse: true, trim: true })
publicId?: string;
@Prop() @Prop()
blameStatus: ReqBlameStatus | StepsEnum; blameStatus: ReqBlameStatus | StepsEnum;

View File

@@ -0,0 +1,40 @@
//! NEW
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { Types } from "mongoose";
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
@Schema({ _id: false })
export class ActorLock {
@Prop({ type: Types.ObjectId, required: true })
actorId: Types.ObjectId;
@Prop({ type: String, required: true })
actorName: string;
@Prop({ type: String, default: "expert" })
actorRole: "expert" | "admin";
}
export const ActorLockSchema = SchemaFactory.createForClass(ActorLock);
@Schema({ _id: false })
export class Workflow {
@Prop({ type: String, enum: WorkflowStep })
currentStep: WorkflowStep;
@Prop({ type: String, enum: WorkflowStep })
nextStep: WorkflowStep;
@Prop({ type: [String], enum: WorkflowStep, default: [] })
completedSteps?: WorkflowStep[];
@Prop({ default: false })
locked?: boolean;
@Prop({ type: Date })
lockedAt?: Date;
@Prop({ type: ActorLockSchema })
lockedBy?: ActorLock;
}
export const WorkflowSchema = SchemaFactory.createForClass(Workflow);

View File

@@ -6,10 +6,13 @@ import { ClientModule } from "src/client/client.module";
import { PlatesModule } from "src/plates/plates.module"; import { PlatesModule } from "src/plates/plates.module";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; 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 { SandHubModule } from "src/sand-hub/sand-hub.module"; import { SandHubModule } from "src/sand-hub/sand-hub.module";
import { UsersModule } from "src/users/users.module"; import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module"; import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module"; import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
import { PublicIdModule } from "src/utils/public-id/public-id.module";
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
import { UserSignDbService } from "./entities/db-service/sign.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service";
@@ -23,12 +26,14 @@ import {
BlameVoicesModel, BlameVoicesModel,
} from "./entities/schema/blame-voice.schema"; } from "./entities/schema/blame-voice.schema";
import { BlameDocumentModel, BlameDocumentSchema } from "./entities/schema/blame-document.schema"; import { BlameDocumentModel, BlameDocumentSchema } from "./entities/schema/blame-document.schema";
import { BlameRequest, BlameRequestSchema } from "./entities/schema/blame-cases.schema";
import { import {
RequestManagementModel, RequestManagementModel,
RequestManagementSchema, RequestManagementSchema,
} from "./entities/schema/request-management.schema"; } from "./entities/schema/request-management.schema";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
import { RequestManagementController } from "./request-management.controller"; import { RequestManagementController } from "./request-management.controller";
import { RequestManagementV2Controller } from "./request-management.v2.controller";
import { ExpertInitiatedController } from "./expert-initiated.controller"; import { ExpertInitiatedController } from "./expert-initiated.controller";
@Module({ @Module({
@@ -37,12 +42,15 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
ClientModule, ClientModule,
SandHubModule, SandHubModule,
SmsManagerModule, SmsManagerModule,
PublicIdModule,
WorkflowStepManagementModule,
PlatesModule, PlatesModule,
MulterModule.register({ MulterModule.register({
dest: "./files/video", dest: "./files/video",
}), }),
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: RequestManagementModel.name, schema: RequestManagementSchema }, { name: RequestManagementModel.name, schema: RequestManagementSchema },
{ name: BlameRequest.name, schema: BlameRequestSchema },
{ name: BlameVideosModel.name, schema: BlameVideoSchema }, { name: BlameVideosModel.name, schema: BlameVideoSchema },
{ name: BlameVoicesModel.name, schema: BlameVoiceSchema }, { name: BlameVoicesModel.name, schema: BlameVoiceSchema },
{ name: UserSignModel.name, schema: UserSignSchema }, { name: UserSignModel.name, schema: UserSignSchema },
@@ -51,7 +59,11 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
forwardRef(() => ClaimRequestManagementModule), forwardRef(() => ClaimRequestManagementModule),
CronModule, CronModule,
], ],
controllers: [RequestManagementController, ExpertInitiatedController], controllers: [
RequestManagementController,
RequestManagementV2Controller,
ExpertInitiatedController,
],
providers: [ providers: [
RequestManagementService, RequestManagementService,
BlameVideoDbService, BlameVideoDbService,
@@ -59,6 +71,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
UserSignDbService, UserSignDbService,
RequestManagementDbService, RequestManagementDbService,
BlameDocumentDbService, BlameDocumentDbService,
BlameRequestDbService,
], ],
exports: [ exports: [
RequestManagementService, RequestManagementService,
@@ -67,6 +80,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
BlameVoiceDbService, BlameVoiceDbService,
BlameDocumentDbService, BlameDocumentDbService,
UserSignDbService, UserSignDbService,
BlameRequestDbService,
], ],
}) })
export class RequestManagementModule {} export class RequestManagementModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Req,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { FileFieldsInterceptor, FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
import { Request } from "express";
import { GlobalGuard } from "src/auth/guards/global.guard";
import { RolesGuard } from "src/auth/guards/role.guard";
import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import {
BlameConfessionDtoV2,
CreateBlameRequestDtoV2,
DescriptionDto,
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
/**
* V2 API surface for the redesigned `BlameRequest` model.
*
* Versioning strategy: prefix the controller route with `v2/` so
* all endpoints under this controller are automatically V2.
*/
@Controller("v2/blame-request-management")
@ApiTags("blame-request-management (v2)")
@ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER)
export class RequestManagementV2Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
) {}
@Post()
@UseGuards(GlobalGuard)
async createRequestV2(
@CurrentUser() user,
@Body() createRequestDto: CreateBlameRequestDtoV2,
) {
return this.requestManagementService.createRequestV2(
user,
createRequestDto.type,
);
}
@Post("/blame-confession/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: BlameConfessionDtoV2 })
@UseGuards(GlobalGuard)
async blameConfessionV2(
@Param("requestId") requestId: string,
@Body() body: BlameConfessionDtoV2,
@CurrentUser() user,
) {
return this.requestManagementService.blameConfessionV2(requestId, body, user);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: {
fileSize: 20 * 1024 * 1024,
},
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const filename = `${file.originalname}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiParam({ name: "requestId" })
@Post("upload-video/:requestId")
@UseGuards(GlobalGuard)
async uploadVideoV2(
@Param("requestId") requestId: string,
@CurrentUser() user,
@UploadedFile() file?: Express.Multer.File,
) {
return this.requestManagementService.uploadFirstPartyVideoV2(
requestId,
file,
user,
);
}
@Post("/initial-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: AddPlateDto })
@UseGuards(GlobalGuard)
async initialFormV2(
@Param("requestId") requestId: string,
@Body() body: AddPlateDto,
@CurrentUser() user,
) {
return this.requestManagementService.initialFormV2(requestId, body, user);
}
@Post("/add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@UseGuards(GlobalGuard)
async addLocationV2(
@Param("requestId") requestId: string,
@Body() body: LocationDto,
@CurrentUser() user,
) {
return this.requestManagementService.addDetailLocationV2(requestId, body, user);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: {
type: "string",
format: "binary",
},
},
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: {
fileSize: 10 * 1024 * 1024,
},
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
const flname = file.originalname.split(".")[0];
const filename = `${flname}-${unique}${ex}`;
callback(null, filename);
},
}),
}),
)
@ApiParam({ name: "requestId" })
@Post("upload-voice/:requestId")
@UseGuards(GlobalGuard)
async uploadVoiceV2(
@Param("requestId") requestId: string,
@CurrentUser() user,
@UploadedFile() voice?: Express.Multer.File,
) {
return this.requestManagementService.uploadVoiceV2(requestId, voice, user);
}
@Post("/add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@UseGuards(GlobalGuard)
async addDescriptionV2(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@CurrentUser() user,
) {
return this.requestManagementService.addDescriptionV2(requestId, body, user);
}
@Post("add-second-party/:phoneNumber/:requestId/:frontendRoute")
@ApiParam({ name: "phoneNumber" })
@ApiParam({ name: "requestId" })
@ApiParam({ name: "frontendRoute" })
@UseGuards(GlobalGuard)
async addSecondPartyV2(
@Param("phoneNumber") phoneNumber: string,
@Param("requestId") requestId: string,
@Param("frontendRoute") frontendRoute: string,
@CurrentUser() user,
) {
return this.requestManagementService.addSecondPartyV2(
requestId,
phoneNumber,
frontendRoute,
user,
);
}
/**
* V2: Get list of documents/items the current user needs to resend
*/
@Get("resend-requirements/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
async getResendRequirements(
@Param("requestId") requestId: string,
@CurrentUser() user: any,
) {
return this.requestManagementService.getResendRequirementsV2(
requestId,
user.sub,
);
}
/**
* V2: User signs/accepts or rejects expert decision
*/
@Put("sign/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description: "Upload signature file and acceptance decision",
schema: {
type: "object",
required: ["sign", "isAccept"],
properties: {
sign: {
type: "string",
format: "binary",
description: "Signature image file",
},
isAccept: {
type: "boolean",
description: "true to accept expert decision, false to reject",
},
},
},
})
@UseInterceptors(
FileInterceptor("sign", {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ext = extname(file.originalname);
const filename = `${file.originalname.split(/[.,\s-]/)[0]}-${unique}${ext}`;
callback(null, filename);
},
}),
}),
)
async userSignDecision(
@Param("requestId") requestId: string,
@Body("isAccept") isAccept: string | boolean,
@CurrentUser() user: any,
@UploadedFile() sign: Express.Multer.File,
) {
// Convert string "true"/"false" to boolean
const acceptDecision = typeof isAccept === "string"
? isAccept === "true"
: Boolean(isAccept);
return this.requestManagementService.userSignDecisionV2(
requestId,
user.sub,
acceptDecision,
sign,
);
}
/**
* V2: Upload requested documents/evidence for resend
* Field names must match ResendItemType enum values (e.g., drivingLicense, carCertificate, voice, video)
*/
@Put("resend/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiConsumes("multipart/form-data")
@ApiBody({
description:
"Upload files with field names matching requested items (e.g., drivingLicense, carCertificate, voice, video)",
schema: {
type: "object",
properties: {
drivingLicense: {
type: "string",
format: "binary",
description: "Driving license file (if requested)",
},
carCertificate: {
type: "string",
format: "binary",
description: "Car certificate file (if requested)",
},
nationalCertificate: {
type: "string",
format: "binary",
description: "National certificate file (if requested)",
},
carGreenCard: {
type: "string",
format: "binary",
description: "Car green card file (if requested)",
},
voice: {
type: "string",
format: "binary",
description: "Voice recording file (if requested)",
},
video: {
type: "string",
format: "binary",
description: "Video file (if requested)",
},
},
},
})
@UseInterceptors(
FileFieldsInterceptor(
[
{ name: "drivingLicense", maxCount: 1 },
{ name: "carCertificate", maxCount: 1 },
{ name: "nationalCertificate", maxCount: 1 },
{ name: "carGreenCard", maxCount: 1 },
{ name: "voice", maxCount: 1 },
{ name: "video", maxCount: 1 },
],
{
storage: diskStorage({
destination: (req, file, cb) => {
cb(null, "./uploads");
},
filename: (req, file, cb) => {
const uniqueSuffix =
Date.now() + "-" + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
},
}),
},
),
)
async uploadResendDocuments(
@Param("requestId") requestId: string,
@CurrentUser() user: any,
@UploadedFiles()
files: {
drivingLicense?: Express.Multer.File[];
carCertificate?: Express.Multer.File[];
nationalCertificate?: Express.Multer.File[];
carGreenCard?: Express.Multer.File[];
voice?: Express.Multer.File[];
video?: Express.Multer.File[];
},
) {
return this.requestManagementService.uploadResendDocumentsV2(
requestId,
user.sub,
files,
);
}
}

View File

@@ -20,6 +20,10 @@ export class SandHubService {
private loginToken: string | null = null; private loginToken: string | null = null;
private tokenExpiry: Date | null = null; private tokenExpiry: Date | null = null;
// Tejarat inquiry auth (82.99.202.245:3027)
private tejaratAccessToken: string | null = null;
private tejaratTokenExpiry: Date | null = null;
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly sandHubDbService: SandHubDbService, private readonly sandHubDbService: SandHubDbService,
@@ -62,6 +66,128 @@ export class SandHubService {
} }
} }
private async getTejaratAccessToken(): Promise<string> {
if (
this.tejaratAccessToken &&
this.tejaratTokenExpiry &&
this.tejaratTokenExpiry > new Date()
) {
return this.tejaratAccessToken;
}
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
const email = process.env.TEJARAT_INQUIRY_EMAIL;
const password = process.env.TEJARAT_INQUIRY_PASSWORD;
if (!email || !password) {
throw new UnauthorizedException(
"Tejarat inquiry credentials are not configured (TEJARAT_INQUIRY_EMAIL/TEJARAT_INQUIRY_PASSWORD)",
);
}
this.logger.log(
"Tejarat inquiry token is missing or expired. Fetching a new one...",
);
try {
const response = await this.httpService.axiosRef.post(
`${baseUrl}/user/login`,
{ email, password },
{
headers: {
Accept: "*/*",
"Content-Type": "application/json",
},
timeout: 30000,
},
);
const token = response.data?.accessToken;
if (!token) {
throw new Error("No access token returned from Tejarat login");
}
this.tejaratAccessToken = token;
// No explicit expiry in response; keep a conservative cache window.
this.tejaratTokenExpiry = new Date(Date.now() + 55 * 60 * 1000);
return this.tejaratAccessToken;
} catch (er) {
this.logger.error("Failed to login to Tejarat inquiry:", er?.message || er);
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
throw new UnauthorizedException("Tejarat inquiry authentication failed");
}
}
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
const INITIAL_DELAY = 500;
const BACKOFF_FACTOR = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const token = await this.getTejaratAccessToken();
try {
const response = await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
timeout: 30000,
});
if (!response?.data) throw new Error("EMPTY_RESPONSE");
return response.data;
} catch (err: any) {
const status = err?.response?.status;
const data = err?.response?.data;
this.logger.error(
`Tejarat request failed (attempt ${attempt + 1}/${maxRetries}) to ${url} with status ${
status ?? "NO_STATUS"
}`,
data ? JSON.stringify(data) : err?.message || err,
);
// If unauthorized, clear token and retry once
if (status === 401) {
this.tejaratAccessToken = null;
this.tejaratTokenExpiry = null;
}
const delay = INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (attempt === maxRetries - 1) {
throw err;
}
}
}
}
/**
* Tejarat block inquiry (replaces SandHub call for V2 flows).
* Returns both raw + mapped (old-format) response.
*/
async getTejaratBlockInquiry(userDetail: SandHubDetailDto): Promise<{
raw: any;
mapped: any;
}> {
const baseUrl =
process.env.TEJARAT_INQUIRY_BASE_URL ?? "http://82.99.202.245:3027";
const requestPayload = {
leftTwoDigits: String(userDetail.plate.leftDigits),
serialLetter: String(userDetail.plate.centerAlphabet),
threeDigits: String(userDetail.plate.centerDigits),
rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer,
};
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
const raw = await this.makeTejaratRequest(requestUrl, requestPayload);
const mapped = this.mapNewApiResponseToOldFormat(raw);
return { raw, mapped };
}
async getSandHubDataFromId(sandHubId) { async getSandHubDataFromId(sandHubId) {
return await this.sandHubDbService.findOneBySandHubId(sandHubId); return await this.sandHubDbService.findOneBySandHubId(sandHubId);
} }

View File

@@ -0,0 +1,16 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
@Schema({ collection: "counters", versionKey: false })
export class CounterModel {
/**
* Counter name (e.g. "requestPublicId")
*/
@Prop({ type: String, required: true })
_id: string;
@Prop({ type: Number, required: true, default: 0 })
seq: number;
}
export const CounterSchema = SchemaFactory.createForClass(CounterModel);

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";
import { CounterModel, CounterSchema } from "./counter.schema";
import { PublicIdService } from "./public-id.service";
@Module({
imports: [
MongooseModule.forFeature([
{ name: CounterModel.name, schema: CounterSchema },
]),
],
providers: [PublicIdService],
exports: [PublicIdService],
})
export class PublicIdModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { CounterModel } from "./counter.schema";
@Injectable()
export class PublicIdService {
private static readonly COUNTER_NAME = "requestPublicId";
private static readonly DIGITS = 5;
private static readonly BASE = 10 ** PublicIdService.DIGITS; // 100000
constructor(
@InjectModel(CounterModel.name)
private readonly counterModel: Model<CounterModel>,
) {}
/**
* Generates a human-friendly id like:
* A00001 ... A99999, B00000 ... Z99999, AA00000, AB00000, ...
*
* - Atomic uniqueness is guaranteed by MongoDB `$inc` on a single counter doc.
* - A unique index on `publicId` in target collections provides extra safety.
*/
async generateRequestPublicId(): Promise<string> {
const counter = await this.counterModel.findOneAndUpdate(
{ _id: PublicIdService.COUNTER_NAME },
{ $inc: { seq: 1 } },
{ upsert: true, new: true },
);
const seq = counter.seq; // starts at 1
const zeroBased = seq - 1;
const prefixIndex = Math.floor(zeroBased / PublicIdService.BASE);
const digits = zeroBased % PublicIdService.BASE;
const prefix = this.indexToLetters(prefixIndex);
const suffix = String(digits).padStart(PublicIdService.DIGITS, "0");
return `${prefix}${suffix}`;
}
/**
* 0 -> A, 25 -> Z, 26 -> AA, 27 -> AB, ...
*/
private indexToLetters(index: number): string {
let n = index;
let result = "";
do {
const rem = n % 26;
result = String.fromCharCode(65 + rem) + result;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return result;
}
}

View File

@@ -0,0 +1,510 @@
/**
* Swagger example bodies for CLAIM workflow step management
*/
export const createClaimWorkflowStepExamples = {
claimCreated: {
summary: 'Claim Created Step',
value: {
stepKey: 'CLAIM_CREATED',
type: 'CLAIM',
stepNumber: 1,
isActive: true,
stepName_fa: 'ایجاد درخواست خسارت',
stepName_en: 'Create Claim Request',
description_fa: 'ایجاد درخواست خسارت از پرونده تعیین تقصیر تکمیل شده - فقط طرف آسیب‌دیده می‌تواند درخواست دهد',
description_en: 'Create claim request from completed blame case - only damaged party can proceed',
category: 'CLAIM',
requiredPreviousSteps: [],
nextPossibleSteps: ['SELECT_OUTER_PARTS'],
allowedRoles: ['user'],
estimatedDuration: 1,
fields: [],
metadata: {
icon: 'file-plus',
color: '#8B5CF6',
requiresBlameCase: true,
blameStatusRequired: 'COMPLETED',
bothPartiesMustAccept: true,
userMustBeDamagedParty: true
}
}
},
selectOuterParts: {
summary: 'Select Outer Parts Step',
value: {
stepKey: 'SELECT_OUTER_PARTS',
type: 'CLAIM',
stepNumber: 2,
isActive: true,
stepName_fa: 'انتخاب قطعات خارجی آسیب‌دیده',
stepName_en: 'Select Damaged Outer Parts',
description_fa: 'انتخاب قطعات خارجی خودرو که در حادثه آسیب دیده‌اند (کاپوت، درب‌ها، سپر و...)',
description_en: 'Select vehicle outer parts damaged in the accident (hood, doors, bumpers, etc.)',
category: 'CLAIM',
requiredPreviousSteps: ['CLAIM_CREATED'],
nextPossibleSteps: ['SELECT_OTHER_PARTS'],
allowedRoles: ['user'],
estimatedDuration: 3,
fields: [
{
id: '67fa10c259e15f231a2d1001',
name_fa: 'قطعات خارجی',
name_en: 'outerParts',
label_fa: 'قطعات خارجی آسیب‌دیده',
label_en: 'Damaged Outer Parts',
placeholder_fa: 'قطعات خارجی آسیب‌دیده را انتخاب کنید',
placeholder_en: 'Select damaged outer parts',
value: [
{ label_fa: 'کاپوت', label_en: 'Hood', value: 'hood' },
{ label_fa: 'درب جلو راست', label_en: 'Front Right Door', value: 'front_right_door' },
{ label_fa: 'درب جلو چپ', label_en: 'Front Left Door', value: 'front_left_door' },
{ label_fa: 'درب عقب راست', label_en: 'Rear Right Door', value: 'rear_right_door' },
{ label_fa: 'درب عقب چپ', label_en: 'Rear Left Door', value: 'rear_left_door' },
{ label_fa: 'سپر جلو', label_en: 'Front Bumper', value: 'front_bumper' },
{ label_fa: 'سپر عقب', label_en: 'Rear Bumper', value: 'rear_bumper' },
{ label_fa: 'گلگیر جلو راست', label_en: 'Front Right Fender', value: 'front_right_fender' },
{ label_fa: 'گلگیر جلو چپ', label_en: 'Front Left Fender', value: 'front_left_fender' },
{ label_fa: 'گلگیر عقب راست', label_en: 'Rear Right Fender', value: 'rear_right_fender' },
{ label_fa: 'گلگیر عقب چپ', label_en: 'Rear Left Fender', value: 'rear_left_fender' },
{ label_fa: 'صندوق عقب', label_en: 'Trunk', value: 'trunk' },
{ label_fa: 'سقف', label_en: 'Roof', value: 'roof' }
],
dataType: 'multiselect',
required: true,
order: 1,
isActive: true,
validation: {
minItems: 1,
maxItems: 13
}
}
],
metadata: {
icon: 'car',
color: '#3B82F6',
requiresMultipleSelection: true
}
}
},
selectOtherParts: {
summary: 'Select Other Parts & Bank Info Step',
value: {
stepKey: 'SELECT_OTHER_PARTS',
type: 'CLAIM',
stepNumber: 3,
isActive: true,
stepName_fa: 'انتخاب سایر قطعات و اطلاعات بانکی',
stepName_en: 'Select Other Parts & Bank Information',
description_fa: 'انتخاب سایر قطعات آسیب‌دیده (غیر از بدنه)، شماره شبا و کد ملی بیمه‌گذار',
description_en: 'Select other damaged parts (non-body), Sheba number and insurer national code',
category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OUTER_PARTS'],
nextPossibleSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
allowedRoles: ['user'],
estimatedDuration: 4,
fields: [
{
id: '67fa10c259e15f231a2d1002',
name_fa: 'سایر قطعات',
name_en: 'otherParts',
label_fa: 'سایر قطعات آسیب‌دیده',
label_en: 'Other Damaged Parts',
placeholder_fa: 'سایر قطعات آسیب‌دیده را انتخاب کنید',
placeholder_en: 'Select other damaged parts',
value: [
{ label_fa: 'موتور', label_en: 'Engine', value: 'engine' },
{ label_fa: 'سیستم تعلیق', label_en: 'Suspension', value: 'suspension' },
{ label_fa: 'سیستم ترمز', label_en: 'Brake System', value: 'brake_system' },
{ label_fa: 'سیستم برق', label_en: 'Electrical System', value: 'electrical' },
{ label_fa: 'رادیاتور', label_en: 'Radiator', value: 'radiator' },
{ label_fa: 'گیربکس', label_en: 'Transmission', value: 'transmission' },
{ label_fa: 'اگزوز', label_en: 'Exhaust', value: 'exhaust' },
{ label_fa: 'چراغ جلو', label_en: 'Headlight', value: 'headlight' },
{ label_fa: 'چراغ عقب', label_en: 'Taillight', value: 'taillight' },
{ label_fa: 'آینه', label_en: 'Mirror', value: 'mirror' },
{ label_fa: 'شیشه', label_en: 'Glass', value: 'glass' }
],
dataType: 'multiselect',
required: false,
order: 1,
isActive: true,
validation: {
minItems: 0,
maxItems: 11
}
},
{
id: '67fa10c259e15f231a2d1003',
name_fa: 'شماره شبا',
name_en: 'shebaNumber',
label_fa: 'شماره شبا',
label_en: 'Sheba Number',
placeholder_fa: 'شماره شبا (24 رقم بدون IR)',
placeholder_en: 'Sheba number (24 digits without IR)',
value: null,
dataType: 'string',
required: true,
order: 2,
isActive: true,
validation: {
pattern: '^[0-9]{24}$',
minLength: 24,
maxLength: 24
}
},
{
id: '67fa10c259e15f231a2d1004',
name_fa: 'کد ملی مالک',
name_en: 'nationalCodeOfOwner',
label_fa: 'کد ملی مالک',
label_en: 'National Code of Owner',
placeholder_fa: 'کد ملی 10 رقمی مالک',
placeholder_en: '10-digit national code of owner',
value: null,
dataType: 'string',
required: true,
order: 3,
isActive: true,
validation: {
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10
}
}
],
metadata: {
icon: 'list',
color: '#10B981',
requiresBankInfo: true
}
}
},
uploadRequiredDocuments: {
summary: 'Upload Required Documents Step',
value: {
stepKey: 'UPLOAD_REQUIRED_DOCUMENTS',
type: 'CLAIM',
stepNumber: 4,
isActive: true,
stepName_fa: 'آپلود مدارک مورد نیاز',
stepName_en: 'Upload Required Documents',
description_fa: 'آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)',
description_en: 'Upload required documents for claim review (dynamic list based on selected parts)',
category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OTHER_PARTS'],
nextPossibleSteps: ['CAPTURE_PART_DAMAGES'],
allowedRoles: ['user'],
estimatedDuration: 5,
fields: [],
metadata: {
icon: 'upload',
color: '#F59E0B',
dynamicFields: true,
requiresAPI: '/claim-request-management/car-part-image-required/:claimRequestID',
note: 'لیست مدارک به صورت داینامیک از API دریافت می‌شود'
}
}
},
capturePartDamages: {
summary: 'Capture Part Damages Step',
value: {
stepKey: 'CAPTURE_PART_DAMAGES',
type: 'CLAIM',
stepNumber: 5,
isActive: true,
stepName_fa: 'ثبت تصاویر آسیب‌های قطعات',
stepName_en: 'Capture Part Damages',
description_fa: 'ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)',
description_en: 'Capture damage images for each part (one request per part)',
category: 'CLAIM',
requiredPreviousSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
nextPossibleSteps: ['USER_SUBMISSION_COMPLETE'],
allowedRoles: ['user'],
estimatedDuration: 10,
fields: [],
metadata: {
icon: 'camera',
color: '#EF4444',
dynamicFields: true,
requiresAPI: '/claim-request-management/capture-car-part-damage/:claimRequestID/:partId',
requiresCamera: true,
multipleRequests: true,
note: 'برای هر قطعه یک درخواست جداگانه ارسال می‌شود'
}
}
},
userSubmissionComplete: {
summary: 'User Submission Complete',
value: {
stepKey: 'USER_SUBMISSION_COMPLETE',
type: 'CLAIM',
stepNumber: 6,
isActive: true,
stepName_fa: 'تکمیل ثبت اطلاعات کاربر',
stepName_en: 'User Submission Complete',
description_fa: 'اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت',
description_en: 'User information completed, waiting for damage expert review',
category: 'CLAIM',
requiredPreviousSteps: ['CAPTURE_PART_DAMAGES'],
nextPossibleSteps: ['EXPERT_DAMAGE_ASSESSMENT'],
allowedRoles: ['user', 'damage_expert', 'admin'],
estimatedDuration: 0,
fields: [],
metadata: {
icon: 'check-circle',
color: '#10B981',
readOnly: true,
isSystemStep: true
}
}
},
expertDamageAssessment: {
summary: 'Expert Damage Assessment',
value: {
stepKey: 'EXPERT_DAMAGE_ASSESSMENT',
type: 'CLAIM',
stepNumber: 7,
isActive: true,
stepName_fa: 'ارزیابی خسارت توسط کارشناس',
stepName_en: 'Expert Damage Assessment',
description_fa: 'بررسی و ارزیابی خسارت توسط کارشناس خسارت',
description_en: 'Damage review and assessment by damage expert',
category: 'EXPERT',
requiredPreviousSteps: ['USER_SUBMISSION_COMPLETE'],
nextPossibleSteps: ['EXPERT_COST_EVALUATION'],
allowedRoles: ['damage_expert', 'admin'],
estimatedDuration: 30,
fields: [
{
id: '67fa10c259e15f231a2d1005',
name_fa: 'وضعیت ارزیابی',
name_en: 'assessmentStatus',
label_fa: 'وضعیت ارزیابی',
label_en: 'Assessment Status',
placeholder_fa: 'وضعیت ارزیابی را انتخاب کنید',
placeholder_en: 'Select assessment status',
value: [
{ label_fa: 'تایید شده', label_en: 'Approved', value: 'approved' },
{ label_fa: 'نیاز به بازنگری', label_en: 'Needs Revision', value: 'needs_revision' },
{ label_fa: 'رد شده', label_en: 'Rejected', value: 'rejected' }
],
dataType: 'select',
required: true,
order: 1,
isActive: true,
validation: {
enum: ['approved', 'needs_revision', 'rejected']
}
},
{
id: '67fa10c259e15f231a2d1006',
name_fa: 'توضیحات کارشناس',
name_en: 'expertComments',
label_fa: 'توضیحات کارشناس',
label_en: 'Expert Comments',
placeholder_fa: 'توضیحات خود را وارد کنید',
placeholder_en: 'Enter your comments',
value: null,
dataType: 'textarea',
required: true,
order: 2,
isActive: true,
validation: {
minLength: 10,
maxLength: 2000
}
}
],
metadata: {
icon: 'clipboard-check',
color: '#8B5CF6',
requiresExpertRole: true
}
}
},
expertCostEvaluation: {
summary: 'Expert Cost Evaluation',
value: {
stepKey: 'EXPERT_COST_EVALUATION',
type: 'CLAIM',
stepNumber: 8,
isActive: true,
stepName_fa: 'برآورد هزینه توسط کارشناس',
stepName_en: 'Expert Cost Evaluation',
description_fa: 'محاسبه و برآورد هزینه تعمیرات توسط کارشناس',
description_en: 'Repair cost calculation and estimation by expert',
category: 'EXPERT',
requiredPreviousSteps: ['EXPERT_DAMAGE_ASSESSMENT'],
nextPossibleSteps: ['INSURER_REVIEW'],
allowedRoles: ['damage_expert', 'admin'],
estimatedDuration: 20,
fields: [
{
id: '67fa10c259e15f231a2d1007',
name_fa: 'هزینه کل برآورد شده',
name_en: 'estimatedTotalCost',
label_fa: 'هزینه کل برآورد شده (ریال)',
label_en: 'Estimated Total Cost (Rials)',
placeholder_fa: 'مبلغ را وارد کنید',
placeholder_en: 'Enter amount',
value: null,
dataType: 'number',
required: true,
order: 1,
isActive: true,
validation: {
min: 0,
max: 999999999999
}
},
{
id: '67fa10c259e15f231a2d1008',
name_fa: 'جزئیات هزینه',
name_en: 'costBreakdown',
label_fa: 'جزئیات هزینه',
label_en: 'Cost Breakdown',
placeholder_fa: 'جزئیات هزینه را وارد کنید',
placeholder_en: 'Enter cost breakdown',
value: null,
dataType: 'textarea',
required: true,
order: 2,
isActive: true,
validation: {
minLength: 20,
maxLength: 5000
}
}
],
metadata: {
icon: 'dollar-sign',
color: '#F59E0B',
requiresExpertRole: true,
requiresCostCalculation: true
}
}
},
insurerReview: {
summary: 'Insurer Review',
value: {
stepKey: 'INSURER_REVIEW',
type: 'CLAIM',
stepNumber: 9,
isActive: true,
stepName_fa: 'بررسی بیمه‌گر',
stepName_en: 'Insurer Review',
description_fa: 'بررسی نهایی و تایید یا رد خسارت توسط بیمه‌گر',
description_en: 'Final review and approval/rejection by insurer',
category: 'INSURER',
requiredPreviousSteps: ['EXPERT_COST_EVALUATION'],
nextPossibleSteps: ['CLAIM_COMPLETED'],
allowedRoles: ['insurer', 'admin'],
estimatedDuration: 60,
fields: [
{
id: '67fa10c259e15f231a2d1009',
name_fa: 'تصمیم بیمه‌گر',
name_en: 'insurerDecision',
label_fa: 'تصمیم بیمه‌گر',
label_en: 'Insurer Decision',
placeholder_fa: 'تصمیم خود را انتخاب کنید',
placeholder_en: 'Select your decision',
value: [
{ label_fa: 'تایید', label_en: 'Approved', value: 'approved' },
{ label_fa: 'رد', label_en: 'Rejected', value: 'rejected' }
],
dataType: 'select',
required: true,
order: 1,
isActive: true,
validation: {
enum: ['approved', 'rejected']
}
},
{
id: '67fa10c259e15f231a2d100a',
name_fa: 'توضیحات بیمه‌گر',
name_en: 'insurerNotes',
label_fa: 'توضیحات بیمه‌گر',
label_en: 'Insurer Notes',
placeholder_fa: 'توضیحات خود را وارد کنید',
placeholder_en: 'Enter your notes',
value: null,
dataType: 'textarea',
required: true,
order: 2,
isActive: true,
validation: {
minLength: 10,
maxLength: 2000
}
}
],
metadata: {
icon: 'shield-check',
color: '#6366F1',
requiresInsurerRole: true,
isFinalStep: false
}
}
},
claimCompleted: {
summary: 'Claim Completed',
value: {
stepKey: 'CLAIM_COMPLETED',
type: 'CLAIM',
stepNumber: 10,
isActive: true,
stepName_fa: 'تکمیل خسارت',
stepName_en: 'Claim Completed',
description_fa: 'خسارت به طور کامل بررسی و نتیجه‌گیری شده است',
description_en: 'Claim has been fully reviewed and concluded',
category: 'FINAL',
requiredPreviousSteps: ['INSURER_REVIEW'],
nextPossibleSteps: [],
allowedRoles: ['user', 'damage_expert', 'insurer', 'admin'],
estimatedDuration: 0,
fields: [],
metadata: {
icon: 'check-circle',
color: '#10B981',
readOnly: true,
isSystemStep: true,
isFinalStep: true
}
}
}
};
export const updateClaimWorkflowStepExamples = {
example1: {
summary: 'Update claim step name',
value: {
stepName_fa: 'انتخاب قطعات خارجی - بروزرسانی شده',
stepName_en: 'Select Outer Parts - Updated',
category: 'CLAIM',
isActive: true
}
},
example2: {
summary: 'Update claim step metadata',
value: {
metadata: {
icon: 'car',
color: '#10B981',
requiresMultipleSelection: true,
priority: 'high'
}
}
}
};

View File

@@ -0,0 +1,254 @@
import { IsNotEmpty, IsString, IsNumber, IsBoolean, IsOptional, IsArray, IsEnum, ValidateNested, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ClaimWorkflowStep } from 'src/Types&Enums/claim-request-management/claim-workflow-steps.enum';
import { WorkflowStep } from 'src/Types&Enums/blame-request-management/blameWorkflow-steps.enum';
import { Types } from 'mongoose';
// Combined enum for validation
const AllWorkflowSteps = { ...WorkflowStep, ...ClaimWorkflowStep };
export class ClaimStepFieldDto {
@ApiProperty({
description: 'Unique identifier for the field',
example: '67fa10c259e15f231a2d1001'
})
@IsNotEmpty()
id: Types.ObjectId;
@ApiProperty({
description: 'Field name in Farsi',
example: 'قطعات خارجی'
})
@IsNotEmpty()
@IsString()
name_fa: string;
@ApiProperty({
description: 'Field name in English',
example: 'outerParts'
})
@IsNotEmpty()
@IsString()
name_en: string;
@ApiPropertyOptional({
description: 'Field label in Farsi',
example: 'قطعات خارجی آسیب‌دیده'
})
@IsOptional()
@IsString()
label_fa?: string;
@ApiPropertyOptional({
description: 'Field label in English',
example: 'Damaged Outer Parts'
})
@IsOptional()
@IsString()
label_en?: string;
@ApiPropertyOptional({
description: 'Placeholder text in Farsi',
example: 'قطعات خارجی آسیب‌دیده را انتخاب کنید'
})
@IsOptional()
@IsString()
placeholder_fa?: string;
@ApiPropertyOptional({
description: 'Placeholder text in English',
example: 'Select damaged outer parts'
})
@IsOptional()
@IsString()
placeholder_en?: string;
@ApiPropertyOptional({
description: 'Possible values for select/multiselect fields (null for non-select fields)',
example: null,
nullable: true,
default: null
})
@IsOptional()
value?: Object[] | null;
@ApiProperty({
description: 'Data type of the field',
example: 'multiselect',
enum: ['string', 'number', 'boolean', 'date', 'select', 'multiselect', 'file', 'textarea', 'email', 'phone', 'object']
})
@IsNotEmpty()
@IsString()
dataType: string;
@ApiPropertyOptional({
description: 'Whether the field is required',
example: true,
default: false
})
@IsOptional()
@IsBoolean()
required?: boolean;
@ApiPropertyOptional({
description: 'Validation rules for the field',
example: { minItems: 1, maxItems: 13 }
})
@IsOptional()
@IsObject()
validation?: Object;
@ApiPropertyOptional({
description: 'Display order of the field in the form',
example: 1
})
@IsOptional()
@IsNumber()
order?: number;
@ApiPropertyOptional({
description: 'Whether the field is active',
example: true,
default: true
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateClaimWorkflowStepDto {
@ApiProperty({
description: 'Unique key identifying the claim workflow step',
example: 'CLAIM_CREATED',
enum: AllWorkflowSteps
})
@IsNotEmpty()
@IsEnum(AllWorkflowSteps)
stepKey: ClaimWorkflowStep | WorkflowStep;
@ApiProperty({
description: 'Workflow type: CLAIM for claim workflow',
example: 'CLAIM',
default: 'CLAIM'
})
@IsNotEmpty()
@IsString()
type: string;
@ApiProperty({
description: 'Order number of the step in the claim workflow',
example: 1
})
@IsNotEmpty()
@IsNumber()
stepNumber: number;
@ApiPropertyOptional({
description: 'Whether the step is active',
example: true,
default: true
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
description: 'Step name in Farsi',
example: 'ایجاد درخواست خسارت'
})
@IsNotEmpty()
@IsString()
stepName_fa: string;
@ApiProperty({
description: 'Step name in English',
example: 'Create Claim Request'
})
@IsNotEmpty()
@IsString()
stepName_en: string;
@ApiPropertyOptional({
description: 'Step description in Farsi',
example: 'ایجاد درخواست خسارت از پرونده تعیین تقصیر تکمیل شده'
})
@IsOptional()
@IsString()
description_fa?: string;
@ApiPropertyOptional({
description: 'Step description in English',
example: 'Create claim request from completed blame case'
})
@IsOptional()
@IsString()
description_en?: string;
@ApiPropertyOptional({
description: 'Category of the step',
example: 'CLAIM',
enum: ['CLAIM', 'EXPERT', 'INSURER', 'FINAL']
})
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({
description: 'Dynamic fields for this step',
type: [ClaimStepFieldDto],
example: []
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ClaimStepFieldDto)
fields?: ClaimStepFieldDto[];
@ApiPropertyOptional({
description: 'Steps that must be completed before this step',
example: [],
enum: AllWorkflowSteps,
isArray: true
})
@IsOptional()
@IsArray()
@IsEnum(AllWorkflowSteps, { each: true })
requiredPreviousSteps?: (ClaimWorkflowStep | WorkflowStep)[];
@ApiPropertyOptional({
description: 'Possible next steps after completing this step',
example: ['SELECT_OUTER_PARTS'],
enum: AllWorkflowSteps,
isArray: true
})
@IsOptional()
@IsArray()
@IsEnum(AllWorkflowSteps, { each: true })
nextPossibleSteps?: (ClaimWorkflowStep | WorkflowStep)[];
@ApiPropertyOptional({
description: 'Additional metadata for the step',
example: { icon: 'file-plus', color: '#8B5CF6' }
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
example: 1
})
@IsOptional()
@IsNumber()
estimatedDuration?: number;
@ApiPropertyOptional({
description: 'Roles allowed to access this step',
example: ['user', 'damage_expert', 'admin']
})
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedRoles?: string[];
}

View File

@@ -0,0 +1,271 @@
import { IsNotEmpty, IsString, IsNumber, IsBoolean, IsOptional, IsArray, IsEnum, ValidateNested, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { WorkflowStep } from 'src/Types&Enums/blame-request-management/blameWorkflow-steps.enum';
import { ClaimWorkflowStep } from 'src/Types&Enums/claim-request-management/claim-workflow-steps.enum';
import { Types } from 'mongoose';
// Combined enum for validation
const AllWorkflowSteps = { ...WorkflowStep, ...ClaimWorkflowStep };
export class StepFieldDto {
@ApiProperty({
description: 'Unique identifier for the field',
example: '507f1f77bcf86cd799439011'
})
@IsNotEmpty()
id: Types.ObjectId;
@ApiProperty({
description: 'Field name in Farsi',
example: 'فایل ویدیو'
})
@IsNotEmpty()
@IsString()
name_fa: string;
@ApiProperty({
description: 'Field name in English',
example: 'videoFile'
})
@IsNotEmpty()
@IsString()
name_en: string;
@ApiPropertyOptional({
description: 'Field label in Farsi',
example: 'فایل ویدیو'
})
@IsOptional()
@IsString()
label_fa?: string;
@ApiPropertyOptional({
description: 'Field label in English',
example: 'Video File'
})
@IsOptional()
@IsString()
label_en?: string;
@ApiPropertyOptional({
description: 'Placeholder text in Farsi',
example: 'فایل ویدیو خود را آپلود کنید'
})
@IsOptional()
@IsString()
placeholder_fa?: string;
@ApiPropertyOptional({
description: 'Placeholder text in English',
example: 'Upload your video file'
})
@IsOptional()
@IsString()
placeholder_en?: string;
@ApiPropertyOptional({
description: 'Possible values for select/multiselect fields (null for non-select fields)',
example: null,
nullable: true,
default: null
})
@IsOptional()
value?: Object[] | null;
@ApiProperty({
description: 'Data type of the field',
example: 'file',
enum: ['string', 'number', 'boolean', 'date', 'select', 'multiselect', 'file', 'textarea', 'email', 'phone']
})
@IsNotEmpty()
@IsString()
dataType: string;
@ApiPropertyOptional({
description: 'Whether the field is required',
example: true,
default: false
})
@IsOptional()
@IsBoolean()
required?: boolean;
@ApiPropertyOptional({
description: 'Validation rules for the field',
example: { maxSize: 52428800, allowedTypes: ['video/mp4', 'video/webm'] }
})
@IsOptional()
@IsObject()
validation?: Object;
@ApiPropertyOptional({
description: 'Display order of the field in the form',
example: 1
})
@IsOptional()
@IsNumber()
order?: number;
@ApiPropertyOptional({
description: 'Whether the field is active',
example: true,
default: true
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateWorkflowStepDto {
@ApiProperty({
description: 'Unique key identifying the workflow step (blame or claim)',
example: 'FIRST_VIDEO',
enum: AllWorkflowSteps
})
@IsNotEmpty()
@IsEnum(AllWorkflowSteps)
stepKey: WorkflowStep | ClaimWorkflowStep;
@ApiProperty({
description: 'Workflow type: THIRD_PARTY (blame) or CLAIM (claim workflow)',
example: 'THIRD_PARTY',
enum: ['THIRD_PARTY', 'CLAIM']
})
@IsNotEmpty()
@IsString()
type: string;
@ApiProperty({
description: 'Order number of the step within this workflow type',
example: 2
})
@IsNotEmpty()
@IsNumber()
stepNumber: number;
@ApiPropertyOptional({
description: 'Whether the step is active',
example: true,
default: true
})
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiProperty({
description: 'Step name in Farsi',
example: 'ویدیو طرف اول'
})
@IsNotEmpty()
@IsString()
stepName_fa: string;
@ApiProperty({
description: 'Step name in English',
example: 'First Party Video'
})
@IsNotEmpty()
@IsString()
stepName_en: string;
@ApiPropertyOptional({
description: 'Step description in Farsi',
example: 'ضبط ویدیو توسط طرف اول'
})
@IsOptional()
@IsString()
description_fa?: string;
@ApiPropertyOptional({
description: 'Step description in English',
example: 'Video capture by first party'
})
@IsOptional()
@IsString()
description_en?: string;
@ApiPropertyOptional({
description: 'Category of the step within workflow (e.g., INITIAL, FIRST_PARTY, EXPERT)',
example: 'FIRST_PARTY',
enum: ['INITIAL', 'FIRST_PARTY', 'SECOND_PARTY', 'CLAIM', 'EXPERT', 'INSURER', 'FINAL']
})
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({
description: 'Dynamic fields for this step',
type: [StepFieldDto],
example: [
{
id: '507f1f77bcf86cd799439011',
name_fa: 'فایل ویدیو',
name_en: 'videoFile',
label_fa: 'فایل ویدیو',
label_en: 'Video File',
value: null,
dataType: 'file',
required: true,
order: 1,
isActive: true,
validation: {
maxSize: 52428800,
allowedTypes: ['video/mp4', 'video/webm']
}
}
]
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => StepFieldDto)
fields?: StepFieldDto[];
@ApiPropertyOptional({
description: 'Steps that must be completed before this step',
example: ['CREATED'],
enum: AllWorkflowSteps,
isArray: true
})
@IsOptional()
@IsArray()
@IsEnum(AllWorkflowSteps, { each: true })
requiredPreviousSteps?: (WorkflowStep | ClaimWorkflowStep)[];
@ApiPropertyOptional({
description: 'Possible next steps after completing this step',
example: ['FIRST_INITIAL_FORM'],
enum: AllWorkflowSteps,
isArray: true
})
@IsOptional()
@IsArray()
@IsEnum(AllWorkflowSteps, { each: true })
nextPossibleSteps?: (WorkflowStep | ClaimWorkflowStep)[];
@ApiPropertyOptional({
description: 'Additional metadata for the step',
example: { icon: 'video', color: '#3B82F6' }
})
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiPropertyOptional({
description: 'Estimated duration in minutes',
example: 5
})
@IsOptional()
@IsNumber()
estimatedDuration?: number;
@ApiPropertyOptional({
description: 'Roles allowed to access this step',
example: ['user', 'expert', 'admin']
})
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedRoles?: string[];
}

View File

@@ -0,0 +1,8 @@
import { PartialType } from '@nestjs/swagger';
import { CreateWorkflowStepDto } from './create-workflow-step.dto';
/**
* Update DTO inherits all fields from Create DTO but makes them all optional
* Supports both blame and claim workflow steps
*/
export class UpdateWorkflowStepDto extends PartialType(CreateWorkflowStepDto) {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { WorkflowStepModel, WorkflowStepsDB } from '../schema/workflow-step.schema';
import { CreateWorkflowStepDto } from '../../dto/create-workflow-step.dto';
import { UpdateWorkflowStepDto } from '../../dto/update-workflow-step.dto';
import { WorkflowStep } from 'src/Types&Enums/blame-request-management/blameWorkflow-steps.enum';
import { ClaimWorkflowStep } from 'src/Types&Enums/claim-request-management/claim-workflow-steps.enum';
// Type alias for combined workflow steps
type AnyWorkflowStep = WorkflowStep | ClaimWorkflowStep;
@Injectable()
export class WorkflowStepDbService {
constructor(
@InjectModel(WorkflowStepsDB)
private readonly workflowStepModel: Model<WorkflowStepModel>,
) {}
/**
* Create a new workflow step
*/
async create(createDto: CreateWorkflowStepDto): Promise<WorkflowStepModel> {
const created = new this.workflowStepModel(createDto);
return await created.save();
}
/**
* Find all active workflow steps
*/
async findAll(): Promise<WorkflowStepModel[]> {
return await this.workflowStepModel
.find({ isActive: true })
.sort({ stepNumber: 1 })
.exec();
}
/**
* Find all workflow steps including inactive ones
*/
async findAllIncludingInactive(): Promise<WorkflowStepModel[]> {
return await this.workflowStepModel
.find()
.sort({ stepNumber: 1 })
.exec();
}
/**
* Find a workflow step by ID
*/
async findById(id: string): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel.findById(id).exec();
}
/**
* Find a workflow step by step key (supports both blame and claim)
*/
async findByStepKey(stepKey: AnyWorkflowStep): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel.findOne({ stepKey }).exec();
}
/**
* Find a workflow step by step number
*/
async findByStepNumber(stepNumber: number): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel.findOne({ stepNumber }).exec();
}
/**
* Find steps by category
*/
async findByCategory(category: string): Promise<WorkflowStepModel[]> {
return await this.workflowStepModel
.find({ category, isActive: true })
.sort({ stepNumber: 1 })
.exec();
}
/**
* Update a workflow step by ID
*/
async update(id: string, updateDto: UpdateWorkflowStepDto): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel
.findByIdAndUpdate(id, updateDto, { new: true })
.exec();
}
/**
* Update a workflow step by step key (supports both blame and claim)
*/
async updateByStepKey(
stepKey: AnyWorkflowStep,
updateDto: UpdateWorkflowStepDto,
): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel
.findOneAndUpdate({ stepKey }, updateDto, { new: true })
.exec();
}
/**
* Soft delete (deactivate) a workflow step
*/
async remove(id: string): Promise<WorkflowStepModel | null> {
return await this.workflowStepModel
.findByIdAndUpdate(id, { isActive: false }, { new: true })
.exec();
}
/**
* Upsert a workflow step by step key (supports both blame and claim)
*/
async upsertByStepKey(
stepKey: AnyWorkflowStep,
data: CreateWorkflowStepDto | UpdateWorkflowStepDto,
): Promise<WorkflowStepModel> {
return await this.workflowStepModel
.findOneAndUpdate({ stepKey }, data, { upsert: true, new: true })
.exec();
}
/**
* Bulk upsert workflow steps
*/
async bulkUpsert(steps: CreateWorkflowStepDto[]): Promise<any> {
const bulkOps = steps.map((step) => ({
updateOne: {
filter: { stepKey: step.stepKey },
update: { $set: step as any },
upsert: true,
},
}));
return await this.workflowStepModel.bulkWrite(bulkOps as any);
}
/**
* Get the next step number
*/
async getNextStepNumber(): Promise<number> {
const maxStep = await this.workflowStepModel
.findOne()
.sort({ stepNumber: -1 })
.select('stepNumber')
.exec();
return maxStep ? maxStep.stepNumber + 1 : 1;
}
/**
* Check if a step exists (supports both blame and claim)
*/
async exists(stepKey: AnyWorkflowStep): Promise<boolean> {
const count = await this.workflowStepModel.countDocuments({ stepKey }).exec();
return count > 0;
}
}

View File

@@ -0,0 +1,119 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Types } from 'mongoose';
import { WorkflowStep } from 'src/Types&Enums/blame-request-management/blameWorkflow-steps.enum';
import { ClaimWorkflowStep } from 'src/Types&Enums/claim-request-management/claim-workflow-steps.enum';
// Combined enum values for both blame and claim workflows
const allWorkflowSteps = [...Object.values(WorkflowStep), ...Object.values(ClaimWorkflowStep)];
export const WorkflowStepsDB = 'WorkflowSteps';
export type FieldValueType = Object | string | number | boolean | null;
/**
* Field definition for dynamic form generation
* Each step can have multiple fields that represent form inputs
*/
@Schema({ _id: false })
export class StepField {
@Prop({ required: true, type: Types.ObjectId })
id: Types.ObjectId;
@Prop({ required: true, type: String })
name_fa: string;
@Prop({ required: true, type: String })
name_en: string;
@Prop({ type: String })
label_fa?: string;
@Prop({ type: String })
label_en?: string;
@Prop({ type: String })
placeholder_fa?: string;
@Prop({ type: String })
placeholder_en?: string;
@Prop({ type: [Object], default: null })
value: Object[] | null;
@Prop({ required: true, type: String })
dataType: string; // e.g., 'string', 'number', 'boolean', 'date', 'select', 'multiselect', 'file', etc.
@Prop({ type: Boolean, default: false })
required?: boolean;
@Prop({ type: Object })
validation?: Object; // Validation rules (min, max, pattern, etc.)
@Prop({ type: Number })
order?: number; // Field order in the form
@Prop({ type: Boolean, default: true })
isActive?: boolean;
}
export const StepFieldSchema = SchemaFactory.createForClass(StepField);
/**
* Workflow Step Model
* Defines the configuration for each step in the workflow
*/
@Schema({ versionKey: false, collection: 'workflowSteps', timestamps: true })
export class WorkflowStepModel {
@Prop({ required: true, type: String, enum: allWorkflowSteps, unique: true })
stepKey: WorkflowStep | ClaimWorkflowStep;
@Prop({ required: true, type: String })
type: string; // 'THIRD_PARTY' (blame workflow) or 'CLAIM' (claim workflow)
@Prop({ required: true, type: Number })
stepNumber: number;
@Prop({ type: Boolean, default: true })
isActive: boolean;
@Prop({ required: true, type: String })
stepName_fa: string;
@Prop({ required: true, type: String })
stepName_en: string;
@Prop({ type: String })
description_fa?: string;
@Prop({ type: String })
description_en?: string;
@Prop({ type: String })
category?: string; // e.g., 'INITIAL', 'FIRST_PARTY', 'SECOND_PARTY', 'EXPERT', 'INSURER'
@Prop({ type: [StepFieldSchema], default: [] })
fields?: StepField[];
@Prop({ type: [String], enum: allWorkflowSteps, default: [] })
requiredPreviousSteps?: (WorkflowStep | ClaimWorkflowStep)[]; // Steps that must be completed before this step
@Prop({ type: [String], enum: allWorkflowSteps, default: [] })
nextPossibleSteps?: (WorkflowStep | ClaimWorkflowStep)[]; // Possible next steps after completing this step
@Prop({ type: Object })
metadata?: Record<string, any>; // Additional metadata for the step
@Prop({ type: Number })
estimatedDuration?: number; // Estimated duration in minutes
@Prop({ type: [String], default: [] })
allowedRoles?: string[]; // Roles allowed to access this step
}
export const WorkflowStepSchema = SchemaFactory.createForClass(WorkflowStepModel);
// Create indexes for better query performance
WorkflowStepSchema.index({ stepKey: 1 }); // Unique via @Prop
WorkflowStepSchema.index({ type: 1, isActive: 1 }); // Query by workflow type
WorkflowStepSchema.index({ type: 1, stepNumber: 1 }, { unique: true }); // Unique stepNumber within workflow type
WorkflowStepSchema.index({ type: 1, category: 1, stepNumber: 1 }); // Sorting within workflow and category

View File

@@ -0,0 +1,8 @@
export * from './workflow-step-management.module';
export * from './workflow-step-management.service';
export * from './workflow-step-management.controller';
export * from './dto/create-workflow-step.dto';
export * from './dto/update-workflow-step.dto';
export * from './dto/workflow-step.examples';
export * from './entities/schema/workflow-step.schema';
export * from './entities/db-service/workflow-step.db.service';

View File

@@ -0,0 +1,353 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBody,
ApiParam,
ApiQuery,
ApiBearerAuth
} from '@nestjs/swagger';
import { WorkflowStepManagementService } from './workflow-step-management.service';
import { CreateWorkflowStepDto } from './dto/create-workflow-step.dto';
import { UpdateWorkflowStepDto } from './dto/update-workflow-step.dto';
import { WorkflowStep } from 'src/Types&Enums/blame-request-management/blameWorkflow-steps.enum';
import { ClaimWorkflowStep } from 'src/Types&Enums/claim-request-management/claim-workflow-steps.enum';
import {
createWorkflowStepExamples,
updateWorkflowStepExamples,
bulkUpsertWorkflowStepExamples,
validateAccessExamples,
claimWorkflowStepExamples,
} from './dto/workflow-step.examples';
@ApiTags('Workflow Step Management')
@Controller('workflow-step-management')
export class WorkflowStepManagementController {
constructor(
private readonly workflowStepManagementService: WorkflowStepManagementService,
) {}
/**
* Create a new workflow step configuration
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Create a new workflow step',
description: 'Creates a new workflow step configuration with fields and validation rules'
})
@ApiBody({
type: CreateWorkflowStepDto,
examples: {
...createWorkflowStepExamples,
...claimWorkflowStepExamples
}
})
@ApiResponse({ status: 201, description: 'Workflow step created successfully' })
@ApiResponse({ status: 400, description: 'Bad request - Invalid input data' })
@ApiResponse({ status: 409, description: 'Conflict - Step already exists' })
async create(@Body() createDto: CreateWorkflowStepDto) {
return await this.workflowStepManagementService.create(createDto);
}
/**
* Get all active workflow steps
*/
@Get()
@ApiOperation({
summary: 'Get all workflow steps',
description: 'Retrieves all workflow steps, optionally including inactive ones'
})
@ApiQuery({
name: 'includeInactive',
required: false,
type: String,
description: 'Set to "true" to include inactive steps',
example: 'false'
})
@ApiResponse({ status: 200, description: 'List of workflow steps retrieved successfully' })
async findAll(@Query('includeInactive') includeInactive?: string) {
if (includeInactive === 'true') {
return await this.workflowStepManagementService.findAllIncludingInactive();
}
return await this.workflowStepManagementService.findAll();
}
/**
* Update a workflow step by ID
*/
@Patch(':id')
@ApiOperation({
summary: 'Update workflow step by ID',
description: 'Updates a workflow step using its database ID'
})
@ApiParam({ name: 'id', description: 'Workflow step ID', example: '507f1f77bcf86cd799439011' })
@ApiBody({
type: UpdateWorkflowStepDto,
examples: updateWorkflowStepExamples
})
@ApiResponse({ status: 200, description: 'Workflow step updated successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async update(@Param('id') id: string, @Body() updateDto: UpdateWorkflowStepDto) {
return await this.workflowStepManagementService.update(id, updateDto);
}
/**
* Update a workflow step by key
*/
@Patch('step/:stepKey')
@ApiOperation({
summary: 'Update workflow step by key',
description: 'Updates a workflow step using its step key from the enum'
})
@ApiParam({
name: 'stepKey',
description: 'Workflow step key (blame or claim)',
example: 'FIRST_VIDEO'
})
@ApiBody({
type: UpdateWorkflowStepDto,
examples: updateWorkflowStepExamples
})
@ApiResponse({ status: 200, description: 'Workflow step updated successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async updateByStepKey(
@Param('stepKey') stepKey: WorkflowStep | ClaimWorkflowStep,
@Body() updateDto: UpdateWorkflowStepDto,
) {
return await this.workflowStepManagementService.updateByStepKey(stepKey as any, updateDto);
}
/**
* Initialize default workflow steps
*/
@Post('initialize')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Initialize default workflow steps',
description: 'Creates default workflow step configurations for the system'
})
@ApiResponse({ status: 200, description: 'Default steps initialized successfully' })
async initialize() {
return await this.workflowStepManagementService.initializeDefaultSteps();
}
/**
* Get a specific step by stepKey or stepNumber (intelligent lookup)
*/
@Get('find/:identifier')
@ApiOperation({
summary: 'Get workflow step by stepKey or stepNumber',
description: 'Retrieves a workflow step by either its stepKey (enum) or stepNumber. Automatically detects which one is provided.'
})
@ApiParam({
name: 'identifier',
description: 'Step key (e.g., "FIRST_VIDEO") or step number (e.g., 3)',
example: 'FIRST_VIDEO',
examples: {
byStepKey: {
summary: 'By Step Key',
value: 'FIRST_VIDEO'
},
byStepNumber: {
summary: 'By Step Number',
value: 3
}
}
})
@ApiResponse({ status: 200, description: 'Workflow step retrieved successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async findByStepKeyOrNumber(@Param('identifier') identifier: string) {
return await this.workflowStepManagementService.findByStepKeyOrNumber(identifier);
}
/**
* Get a specific step by ID
*/
@Get(':id')
@ApiOperation({
summary: 'Get workflow step by ID',
description: 'Retrieves a specific workflow step by its database ID'
})
@ApiParam({ name: 'id', description: 'Workflow step ID', example: '507f1f77bcf86cd799439011' })
@ApiResponse({ status: 200, description: 'Workflow step retrieved successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async findById(@Param('id') id: string) {
return await this.workflowStepManagementService.findById(id);
}
/**
* Soft delete a step
*/
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Delete workflow step',
description: 'Soft deletes a workflow step by setting isActive to false'
})
@ApiParam({ name: 'id', description: 'Workflow step ID', example: '507f1f77bcf86cd799439011' })
@ApiResponse({ status: 200, description: 'Workflow step deleted successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async remove(@Param('id') id: string) {
return await this.workflowStepManagementService.remove(id);
}
// ============================================================
// DEPRECATED ENDPOINTS - Will be removed in future versions
// ============================================================
/**
* @deprecated Use individual create endpoint instead
* Bulk upsert steps (for initial setup or migrations)
*/
@Post('bulk-upsert')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '[DEPRECATED] Bulk upsert workflow steps',
description: 'This endpoint is deprecated and will be removed in a future version. Use the individual create endpoint instead.',
deprecated: true
})
@ApiBody({
type: [CreateWorkflowStepDto],
examples: bulkUpsertWorkflowStepExamples
})
@ApiResponse({ status: 200, description: 'Steps upserted successfully' })
async bulkUpsert(@Body() steps: CreateWorkflowStepDto[]) {
return await this.workflowStepManagementService.bulkUpsert(steps);
}
/**
* @deprecated This endpoint is no longer needed
* Validate step access
*/
@Post('validate-access')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '[DEPRECATED] Validate step access',
description: 'This endpoint is deprecated and will be removed in a future version.',
deprecated: true
})
@ApiBody({
schema: {
type: 'object',
properties: {
stepKey: {
type: 'string',
description: 'Workflow step key (blame or claim)',
example: 'FIRST_VIDEO'
},
completedSteps: {
type: 'array',
items: {
type: 'string'
},
example: ['CREATED']
}
}
},
examples: validateAccessExamples
})
@ApiResponse({ status: 200, description: 'Validation result returned' })
async validateAccess(
@Body() body: { stepKey: WorkflowStep | ClaimWorkflowStep; completedSteps: (WorkflowStep | ClaimWorkflowStep)[] },
) {
return await this.workflowStepManagementService.validateStepAccess(
body.stepKey as any,
body.completedSteps as any,
);
}
/**
* @deprecated This endpoint is no longer needed
* Get steps by category
*/
@Get('category/:category')
@ApiOperation({
summary: '[DEPRECATED] Get steps by category',
description: 'This endpoint is deprecated and will be removed in a future version.',
deprecated: true
})
@ApiParam({
name: 'category',
description: 'Step category',
example: 'FIRST_PARTY',
enum: ['INITIAL', 'FIRST_PARTY', 'SECOND_PARTY', 'RESOLUTION']
})
@ApiResponse({ status: 200, description: 'Steps retrieved successfully' })
async findByCategory(@Param('category') category: string) {
return await this.workflowStepManagementService.findByCategory(category);
}
/**
* @deprecated This endpoint is no longer needed
* Get next possible steps from current step
*/
@Get('next-steps/:stepKey')
@ApiOperation({
summary: '[DEPRECATED] Get next possible steps',
description: 'This endpoint is deprecated and will be removed in a future version.',
deprecated: true
})
@ApiParam({
name: 'stepKey',
description: 'Current workflow step key (blame or claim)',
example: 'FIRST_VIDEO'
})
@ApiResponse({ status: 200, description: 'Next steps retrieved successfully' })
async getNextSteps(@Param('stepKey') stepKey: WorkflowStep | ClaimWorkflowStep) {
return await this.workflowStepManagementService.getNextSteps(stepKey as any);
}
/**
* @deprecated This endpoint is no longer needed
* Get step fields (for form generation)
*/
@Get('fields/:stepKey')
@ApiOperation({
summary: '[DEPRECATED] Get step fields',
description: 'This endpoint is deprecated and will be removed in a future version.',
deprecated: true
})
@ApiParam({
name: 'stepKey',
description: 'Workflow step key (blame or claim)',
example: 'FIRST_VIDEO'
})
@ApiResponse({ status: 200, description: 'Step fields retrieved successfully' })
async getStepFields(@Param('stepKey') stepKey: WorkflowStep | ClaimWorkflowStep) {
return await this.workflowStepManagementService.getStepFields(stepKey as any);
}
/**
* @deprecated Use findById instead
* Get a specific step by key
*/
@Get('step/:stepKey')
@ApiOperation({
summary: '[DEPRECATED] Get workflow step by key',
description: 'This endpoint is deprecated and will be removed in a future version. Use GET /:id instead.',
deprecated: true
})
@ApiParam({
name: 'stepKey',
description: 'Workflow step key (blame or claim)',
example: 'FIRST_VIDEO'
})
@ApiResponse({ status: 200, description: 'Workflow step retrieved successfully' })
@ApiResponse({ status: 404, description: 'Workflow step not found' })
async findByStepKey(@Param('stepKey') stepKey: WorkflowStep | ClaimWorkflowStep) {
return await this.workflowStepManagementService.findByStepKey(stepKey as any);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { WorkflowStepManagementService } from './workflow-step-management.service';
import { WorkflowStepManagementController } from './workflow-step-management.controller';
import { WorkflowStepDbService } from './entities/db-service/workflow-step.db.service';
import {
WorkflowStepsDB,
WorkflowStepSchema,
} from './entities/schema/workflow-step.schema';
@Module({
imports: [
MongooseModule.forFeature([
{ name: WorkflowStepsDB, schema: WorkflowStepSchema },
]),
],
controllers: [WorkflowStepManagementController],
providers: [WorkflowStepManagementService, WorkflowStepDbService],
exports: [WorkflowStepManagementService, WorkflowStepDbService],
})
export class WorkflowStepManagementModule {}

File diff suppressed because it is too large Load Diff