diff --git a/package.json b/package.json index e36dd5b..1527bc8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@arashioz/errjson-talieh": "^2.2.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/common": "^10.4.15", "@nestjs/core": "^10.4.15", @@ -34,8 +34,8 @@ "@nestjs/platform-fastify": "^10.4.15", "@nestjs/platform-socket.io": "^10.4.15", "@nestjs/schedule": "^4.1.2", - "@nestjs/serve-static": "^5.0.3", - "@nestjs/swagger": "^8.1.0", + "@nestjs/serve-static": "^4.0.2", + "@nestjs/swagger": "^7.4.2", "@nestjs/websockets": "^10.4.15", "@types/uuid": "^10.0.0", "axios": "^1.9.0", @@ -63,7 +63,7 @@ "yargs": "^17.7.2" }, "devDependencies": { - "@nestjs/cli": "^10.4.9", + "@nestjs/cli": "^11.0.14", "@nestjs/schematics": "^10.2.3", "@nestjs/testing": "^10.4.15", "@types/express": "^5.0.0", diff --git a/src/Types&Enums/blame-request-management/blameRequestType.enum.ts b/src/Types&Enums/blame-request-management/blameRequestType.enum.ts new file mode 100644 index 0000000..c1177ed --- /dev/null +++ b/src/Types&Enums/blame-request-management/blameRequestType.enum.ts @@ -0,0 +1,5 @@ +//! NEW +export enum BlameRequestType { + THIRD_PARTY = "THIRD_PARTY", + CAR_BODY = "CAR_BODY", +} diff --git a/src/Types&Enums/blame-request-management/blameStatus.enum.ts b/src/Types&Enums/blame-request-management/blameStatus.enum.ts new file mode 100644 index 0000000..da96d49 --- /dev/null +++ b/src/Types&Enums/blame-request-management/blameStatus.enum.ts @@ -0,0 +1,8 @@ +//! NEW +export enum BlameStatus { + UNKNOWN = "UNKNOWN", + + AGREED = "AGREED", + + DISAGREEMENT = "DISAGREEMENT" + } \ No newline at end of file diff --git a/src/Types&Enums/blame-request-management/blameWorkflow-steps.enum.ts b/src/Types&Enums/blame-request-management/blameWorkflow-steps.enum.ts new file mode 100644 index 0000000..9d19a5a --- /dev/null +++ b/src/Types&Enums/blame-request-management/blameWorkflow-steps.enum.ts @@ -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" + } \ No newline at end of file diff --git a/src/Types&Enums/blame-request-management/caseStatus.enum.ts b/src/Types&Enums/blame-request-management/caseStatus.enum.ts new file mode 100644 index 0000000..317cc6d --- /dev/null +++ b/src/Types&Enums/blame-request-management/caseStatus.enum.ts @@ -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" + } \ No newline at end of file diff --git a/src/Types&Enums/blame-request-management/resendItemType.enum.ts b/src/Types&Enums/blame-request-management/resendItemType.enum.ts new file mode 100644 index 0000000..f57ad56 --- /dev/null +++ b/src/Types&Enums/blame-request-management/resendItemType.enum.ts @@ -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", +} diff --git a/src/Types&Enums/claim-request-management/claim-case-status.enum.ts b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts new file mode 100644 index 0000000..e79b502 --- /dev/null +++ b/src/Types&Enums/claim-request-management/claim-case-status.enum.ts @@ -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", +} diff --git a/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts new file mode 100644 index 0000000..2129b2b --- /dev/null +++ b/src/Types&Enums/claim-request-management/claim-workflow-steps.enum.ts @@ -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", +} diff --git a/src/Types&Enums/claim-request-management/claimStatus.enum.ts b/src/Types&Enums/claim-request-management/claimStatus.enum.ts new file mode 100644 index 0000000..9898e10 --- /dev/null +++ b/src/Types&Enums/claim-request-management/claimStatus.enum.ts @@ -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", +} diff --git a/src/Types&Enums/claim-request-management/required-document-type.enum.ts b/src/Types&Enums/claim-request-management/required-document-type.enum.ts index b32fe5e..d4cceba 100644 --- a/src/Types&Enums/claim-request-management/required-document-type.enum.ts +++ b/src/Types&Enums/claim-request-management/required-document-type.enum.ts @@ -1,4 +1,7 @@ export enum ClaimRequiredDocumentType { + // Car green card + CAR_GREEN_CARD = "car_green_card", + // Damaged party documents DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back", DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front", @@ -16,3 +19,10 @@ export enum ClaimRequiredDocumentType { GUILTY_METAL_PLATE = "guilty_metal_plate", } +export enum CarAngle { + FRONT = "front", + BACK = "back", + LEFT = "left", + RIGHT = "right", +} + diff --git a/src/Types&Enums/damage-expert.enum.ts b/src/Types&Enums/damage-expert.enum.ts index d71319a..1ab6f7a 100644 --- a/src/Types&Enums/damage-expert.enum.ts +++ b/src/Types&Enums/damage-expert.enum.ts @@ -30,3 +30,11 @@ export enum SkillEnum { + + + + + + + + diff --git a/src/app.module.ts b/src/app.module.ts index 804a856..293300b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,7 @@ import { ReportsModule } from "./reports/reports.module"; import { RequestManagementModule } from "./request-management/request-management.module"; import { UsersModule } from "./users/users.module"; import { CronModule } from "./utils/cron/cron.module"; +import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; dotenv.config(); dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); @@ -59,6 +60,7 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` }); ReportsModule, ExpertInsurerModule, LookupsModule, + WorkflowStepManagementModule, ], controllers: [], providers: [], diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 10c5d70..f236f43 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -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 { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.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"; function pick(obj: Record, keys: string[]) { @@ -47,7 +47,7 @@ export class ActorAuthService { private readonly damageExpertDbService: DamageExpertDbService, private readonly fieldExpertDbService: FieldExpertDbService, private readonly insurerExpertDbService: InsurerExpertDbService, - private readonly mailService: MailService, + // private readonly mailService: MailService, // Mailer disabled – not used private readonly clientDbService: ClientDbService, private readonly otpService: OtpService, ) {} @@ -279,17 +279,19 @@ export class ActorAuthService { } const otp = this.otpService.create(); - const sendEmail = await this.mailService.sendMail(normalizedEmail, otp); - - if (sendEmail) { - const hashOtp = await this.hashService.hash(otp); - - await dbServiceToUpdate.findOneAndUpdate(filter, { otp: hashOtp }); - - return sendEmail; - } else { - throw new BadGatewayException("mailServer in unavailable"); - } + // Mailer disabled – not used + // const sendEmail = await this.mailService.sendMail(normalizedEmail, otp); + // if (sendEmail) { + const hashOtp = await this.hashService.hash(otp); + await dbServiceToUpdate.findOneAndUpdate(filter, { otp: hashOtp }); + return { + message: "OTP generated (mail sending disabled)", + code: 200, + emailSent: false, + }; + // } else { + // throw new BadGatewayException("mailServer in unavailable"); + // } } async forgetPasswordVerify(email, otp, newPassword) { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ab9cb68..230996c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -10,13 +10,13 @@ import { UserAuthService } from "src/auth/auth-services/user.auth.service"; import { ClientModule } from "src/client/client.module"; import { UsersModule } from "src/users/users.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 { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module"; @Module({ imports: [ - MailModule, + // MailModule, // Mailer disabled – not used UsersModule, ClientModule, HashModule, diff --git a/src/auth/dto/actor/profile.actor.dto.ts b/src/auth/dto/actor/profile.actor.dto.ts index 718f430..2987aa4 100644 --- a/src/auth/dto/actor/profile.actor.dto.ts +++ b/src/auth/dto/actor/profile.actor.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiSchema } from "@nestjs/swagger"; +import { ApiProperty } from "@nestjs/swagger"; import { IsMobilePhone, IsString, Length } from "class-validator"; 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 { @ApiProperty({ type: "string", diff --git a/src/claim-request-management/claim-request-management.controller.ts b/src/claim-request-management/claim-request-management.controller.ts index d582ecb..9f2619c 100644 --- a/src/claim-request-management/claim-request-management.controller.ts +++ b/src/claim-request-management/claim-request-management.controller.ts @@ -31,6 +31,7 @@ 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 { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; import { CarDamagePartDto, OtherCarDamagePartDto } from "./dto/car-part.dto"; import { UserCommentDto } from "./dto/user-comment.dto"; import { UserObjectionDto } from "./dto/user-objection.dto"; @@ -167,20 +168,7 @@ export class ClaimRequestManagementController { @ApiParam({ name: "claimRequestID" }) @ApiQuery({ name: "documentType", - enum: [ - "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", - ], + enum: ClaimRequiredDocumentType, description: "Type of required document to upload", }) @Patch("upload-required-document/:claimRequestID") diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 21c916c..440b741 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -5,9 +5,12 @@ import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { RequestManagementModule } from "src/request-management/request-management.module"; import { UsersModule } from "src/users/users.module"; import { ClaimRequestManagementController } from "./claim-request-management.controller"; +import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { ClaimRequestManagementService } from "./claim-request-management.service"; import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; +import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service"; +import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema"; import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; @@ -38,12 +41,14 @@ import { VideoCaptureModel, VideoCaptureSchema, } from "./entites/schema/video-capture.schema"; +import { PublicIdModule } from "src/utils/public-id/public-id.module"; import { ClientModule } from "src/client/client.module"; import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { JwtModule } from "@nestjs/jwt"; @Module({ imports: [ + PublicIdModule, UsersModule, RequestManagementModule, AiModule, @@ -51,6 +56,7 @@ import { JwtModule } from "@nestjs/jwt"; ClientModule, JwtModule.register({}), MongooseModule.forFeature([ + { name: ClaimCase.name, schema: ClaimCaseSchema }, { name: ClaimRequestManagementModel.name, schema: ClaimRequestManagementSchema, @@ -69,7 +75,9 @@ import { JwtModule } from "@nestjs/jwt"; providers: [ ClaimRequestManagementService, ClaimRequestManagementDbService, + ClaimCaseDbService, CarGreenCardDbService, + ClaimCaseDbService, DamageImageDbService, ClaimSignDbService, ClaimFactorsImageDbService, @@ -77,10 +85,11 @@ import { JwtModule } from "@nestjs/jwt"; ClaimRequiredDocumentDbService, ClaimAccessGuard, ], - controllers: [ClaimRequestManagementController], + controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller], exports: [ ClaimRequestManagementService, ClaimRequestManagementDbService, + ClaimCaseDbService, DamageImageDbService, VideoCaptureDbService, ClaimRequiredDocumentDbService, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 0bd2263..355b11f 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException, + ConflictException, + InternalServerErrorException, } from "@nestjs/common"; import { Types } from "mongoose"; 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 { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service"; import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service"; +import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service"; +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 { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service"; @@ -41,12 +58,11 @@ import { ClaimRequestManagementModel, UserClaimRating, } 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 { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service"; import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum"; -import { BranchDbService } from "src/client/entities/db-service/branch.db.service"; -import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum"; -import { RoleEnum } from "src/Types&Enums/role.enum"; +import { BranchDbService } from "src/client/entities/db-service/branch.db.service";import { RoleEnum } from "src/Types&Enums/role.enum"; import { UserRatingDto } from "./dto/user-rating.dto"; @Injectable() @@ -75,6 +91,8 @@ export class ClaimRequestManagementService { constructor( private readonly blameDbService: RequestManagementDbService, private readonly claimDbService: ClaimRequestManagementDbService, + private readonly claimCaseDbService: ClaimCaseDbService, + private readonly blameRequestDbService: BlameRequestDbService, private readonly carGreenCardDbService: CarGreenCardDbService, private readonly damageImageDbService: DamageImageDbService, private readonly userDbService: UserDbService, @@ -86,6 +104,7 @@ export class ClaimRequestManagementService { private readonly damageExpertDbService: DamageExpertDbService, private readonly branchDbService: BranchDbService, private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService, + private readonly publicIdService: PublicIdService, ) {} private delay(ms: number): Promise { @@ -157,8 +176,22 @@ export class ClaimRequestManagementService { if (!blameRequest) { 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({ "blameFile._id": new Types.ObjectId(requestId), + blameRequestId: new Types.ObjectId(requestId), + publicId, }); if (existingClaimCount > 0) { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 = {}; + 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 = {}; + 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 { + const prefix = "CL"; + const randomPart = Math.floor(10000 + Math.random() * 90000); // 5 digits + return `${prefix}${randomPart}`; + } } diff --git a/src/claim-request-management/claim-request-management.v2.controller.ts b/src/claim-request-management/claim-request-management.v2.controller.ts new file mode 100644 index 0000000..349b801 --- /dev/null +++ b/src/claim-request-management/claim-request-management.v2.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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", + ); + } + } +} diff --git a/src/claim-request-management/dto/capture-part-v2.dto.ts b/src/claim-request-management/dto/capture-part-v2.dto.ts new file mode 100644 index 0000000..afa03a5 --- /dev/null +++ b/src/claim-request-management/dto/capture-part-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/capture-requirements-v2.dto.ts b/src/claim-request-management/dto/capture-requirements-v2.dto.ts new file mode 100644 index 0000000..6c9cb97 --- /dev/null +++ b/src/claim-request-management/dto/capture-requirements-v2.dto.ts @@ -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; + }; +} diff --git a/src/claim-request-management/dto/claim-details-v2.dto.ts b/src/claim-request-management/dto/claim-details-v2.dto.ts new file mode 100644 index 0000000..9496341 --- /dev/null +++ b/src/claim-request-management/dto/claim-details-v2.dto.ts @@ -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; + + @ApiPropertyOptional({ description: 'Car angles captured' }) + carAngles?: Record; + + @ApiPropertyOptional({ description: 'Damaged parts captured' }) + damagedParts?: Record; + + @ApiProperty({ description: 'Created at' }) + createdAt: string; + + @ApiProperty({ description: 'Updated at' }) + updatedAt: string; +} diff --git a/src/claim-request-management/dto/create-claim-v2.dto.ts b/src/claim-request-management/dto/create-claim-v2.dto.ts new file mode 100644 index 0000000..8b753d9 --- /dev/null +++ b/src/claim-request-management/dto/create-claim-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/my-claims-v2.dto.ts b/src/claim-request-management/dto/my-claims-v2.dto.ts new file mode 100644 index 0000000..e593c0b --- /dev/null +++ b/src/claim-request-management/dto/my-claims-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/select-other-parts-v2.dto.ts b/src/claim-request-management/dto/select-other-parts-v2.dto.ts new file mode 100644 index 0000000..eadfe3a --- /dev/null +++ b/src/claim-request-management/dto/select-other-parts-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/select-outer-parts-v2.dto.ts b/src/claim-request-management/dto/select-outer-parts-v2.dto.ts new file mode 100644 index 0000000..f1e956d --- /dev/null +++ b/src/claim-request-management/dto/select-outer-parts-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/dto/upload-document-v2.dto.ts b/src/claim-request-management/dto/upload-document-v2.dto.ts new file mode 100644 index 0000000..ade2c06 --- /dev/null +++ b/src/claim-request-management/dto/upload-document-v2.dto.ts @@ -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; +} diff --git a/src/claim-request-management/entites/db-service/claim-case.db.service.ts b/src/claim-request-management/entites/db-service/claim-case.db.service.ts new file mode 100644 index 0000000..669279c --- /dev/null +++ b/src/claim-request-management/entites/db-service/claim-case.db.service.ts @@ -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, + ) {} + + async create(payload: Partial): Promise { + return this.claimCaseModel.create(payload); + } + + async findById(id: string | Types.ObjectId): Promise { + return this.claimCaseModel.findById(id); + } + + async findOne(filter: FilterQuery): Promise { + return this.claimCaseModel.findOne(filter); + } + + async findByIdAndUpdate( + id: string | Types.ObjectId, + update: any, + ): Promise { + return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true }); + } + + async find( + filter: FilterQuery, + options?: { lean?: boolean; select?: string }, + ): Promise[]> { + 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[]>; + } + const query = options?.select + ? this.claimCaseModel.find(filter).select(options.select) + : this.claimCaseModel.find(filter); + return query.exec() as Promise; + } + + async countByFilter(filter: FilterQuery): Promise { + return this.claimCaseModel.countDocuments(filter); + } +} diff --git a/src/claim-request-management/entites/db-service/video-capture.db.service.ts b/src/claim-request-management/entites/db-service/video-capture.db.service.ts index 91b7101..ec00cff 100644 --- a/src/claim-request-management/entites/db-service/video-capture.db.service.ts +++ b/src/claim-request-management/entites/db-service/video-capture.db.service.ts @@ -20,6 +20,6 @@ export class VideoCaptureDbService { } async findById(id: string): Promise { - return this.videoCapture.findById(id).lean(); + return this.videoCapture.findById(id); } } diff --git a/src/claim-request-management/entites/schema/claim-case.damage.schema.ts b/src/claim-request-management/entites/schema/claim-case.damage.schema.ts new file mode 100644 index 0000000..6dfb5fd --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.damage.schema.ts @@ -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); + diff --git a/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts new file mode 100644 index 0000000..1eada58 --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.evaluation.schema.ts @@ -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); + diff --git a/src/claim-request-management/entites/schema/claim-case.identity.schema.ts b/src/claim-request-management/entites/schema/claim-case.identity.schema.ts new file mode 100644 index 0000000..31b11f9 --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.identity.schema.ts @@ -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); + diff --git a/src/claim-request-management/entites/schema/claim-case.media.schema.ts b/src/claim-request-management/entites/schema/claim-case.media.schema.ts new file mode 100644 index 0000000..7dced36 --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.media.schema.ts @@ -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; + + /** + * V2: Damaged parts captures + * Map of part key to captured image + */ + @Prop({ + type: Map, + of: CapturedImageSchema, + default: () => ({}), + }) + damagedParts?: Map; +} +export const ClaimMediaSchema = SchemaFactory.createForClass(ClaimMedia); + diff --git a/src/claim-request-management/entites/schema/claim-case.snapshot.schema.ts b/src/claim-request-management/entites/schema/claim-case.snapshot.schema.ts new file mode 100644 index 0000000..b2f6351 --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.snapshot.schema.ts @@ -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); + diff --git a/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts new file mode 100644 index 0000000..ccdec8a --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-case.workflow.schema.ts @@ -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); + diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts new file mode 100644 index 0000000..efd8410 --- /dev/null +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -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; + + @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; +export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase); + + diff --git a/src/claim-request-management/entites/schema/claim-request-management.schema.ts b/src/claim-request-management/entites/schema/claim-request-management.schema.ts index 2afc867..8bb6624 100644 --- a/src/claim-request-management/entites/schema/claim-request-management.schema.ts +++ b/src/claim-request-management/entites/schema/claim-request-management.schema.ts @@ -241,6 +241,22 @@ export class ClaimRequestManagementModel { @Prop({ type: RequestManagementModel, unique: true }) 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 }) requestNumber?: number; diff --git a/src/expert-blame/dto/all-request.dto.ts b/src/expert-blame/dto/all-request.dto.ts index b800908..e70ee01 100644 --- a/src/expert-blame/dto/all-request.dto.ts +++ b/src/expert-blame/dto/all-request.dto.ts @@ -101,3 +101,27 @@ export class AllRequestDtoRs { 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; + } +} diff --git a/src/expert-blame/dto/reply.dto.ts b/src/expert-blame/dto/reply.dto.ts index 12f5d64..12c6d71 100644 --- a/src/expert-blame/dto/reply.dto.ts +++ b/src/expert-blame/dto/reply.dto.ts @@ -3,42 +3,81 @@ import { Types } from "mongoose"; import { BlameDocumentType } from "src/request-management/entities/schema/blame-document.schema"; export class AccidentWayIF { - @ApiProperty({ required: true }) + @ApiProperty({ required: true, example: "9" }) id: string; - @ApiProperty({ required: true }) + @ApiProperty({ required: true, example: "جلو به پهلو" }) label: string; } export class AccidentReasonIF { - @ApiProperty({ required: true }) + @ApiProperty({ required: true, example: "23" }) id: string; - @ApiProperty({ required: true }) + @ApiProperty({ required: true, example: "عدم رعايت فاصله جانبی" }) label: string; - @ApiProperty({ required: true }) + @ApiProperty({ required: true, example: 3 }) fanavaran: number; } export class FieldsInterface { - @ApiProperty({ required: true }) + @ApiProperty({ + required: true, + example: { id: "9", label: "جلو به پهلو" }, + }) accidentWay: AccidentWayIF; - @ApiProperty({ required: true }) + @ApiProperty({ + required: true, + example: { + id: "23", + label: "عدم رعايت فاصله جانبی", + fanavaran: 3, + }, + }) accidentReason: AccidentReasonIF; - @ApiProperty({ required: true }) + @ApiProperty({ + required: true, + example: { + id: "3", + label: "برخورد یک وسیله نقلیه با وسیله نقلیه پارک شده", + }, + }) accidentType: AccidentWayIF; } export class SubmitReplyDto { - @ApiProperty({ required: true }) + @ApiProperty({ + required: true, + example: "Expert's decision explanation", + description: "Detailed explanation of the expert's decision", + }) description: string; - @ApiProperty({ required: true, type: Types.ObjectId }) + @ApiProperty({ + required: true, + type: String, + example: "507f1f77bcf86cd799439011", + description: "ObjectId of the guilty party", + }) 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; } diff --git a/src/expert-blame/dto/resend.dto.ts b/src/expert-blame/dto/resend.dto.ts new file mode 100644 index 0000000..3a28474 --- /dev/null +++ b/src/expert-blame/dto/resend.dto.ts @@ -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[]; +} diff --git a/src/expert-blame/expert-blame.module.ts b/src/expert-blame/expert-blame.module.ts index 16c7908..489f373 100644 --- a/src/expert-blame/expert-blame.module.ts +++ b/src/expert-blame/expert-blame.module.ts @@ -3,12 +3,13 @@ import { ClientModule } from "src/client/client.module"; import { PlatesModule } from "src/plates/plates.module"; import { UsersModule } from "src/users/users.module"; import { ExpertBlameController } from "./expert-blame.controller"; +import { ExpertBlameV2Controller } from "./expert-blame.v2.controller"; import { ExpertBlameService } from "./expert-blame.service"; import { RequestManagementModule } from "src/request-management/request-management.module"; @Module({ imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule], - controllers: [ExpertBlameController], + controllers: [ExpertBlameController, ExpertBlameV2Controller], providers: [ExpertBlameService], exports: [ExpertBlameService], }) diff --git a/src/expert-blame/expert-blame.service.ts b/src/expert-blame/expert-blame.service.ts index d88286f..dd14c80 100644 --- a/src/expert-blame/expert-blame.service.ts +++ b/src/expert-blame/expert-blame.service.ts @@ -1,12 +1,19 @@ import { BadRequestException, ForbiddenException, + HttpException, Injectable, + InternalServerErrorException, Logger, NotFoundException, } from "@nestjs/common"; 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 { SubmitReplyDto } from "./dto/reply.dto"; 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 { ClientDbService } from "src/client/entities/db-service/client.db.service"; 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 { toJalaliDateAndTime } from "src/helpers/date-jalali"; +import { ResendRequestDto } from "./dto/resend.dto"; import { readFile } from "fs/promises"; import { ExpertDbService } from "src/users/entities/db-service/expert.db.service"; import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service"; @@ -28,11 +41,26 @@ interface CheckedRequestEntry { [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() export class ExpertBlameService { private readonly logger = new Logger(ExpertBlameService.name); constructor( private readonly requestManagementDbService: RequestManagementDbService, + private readonly blameRequestDbService: BlameRequestDbService, private readonly clientDbService: ClientDbService, private readonly blameVideoDbService: BlameVideoDbService, private readonly blameVoiceDbService: BlameVoiceDbService, @@ -148,6 +176,108 @@ export class ExpertBlameService { 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 { + 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[]).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, + ): 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; + 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) { return setTimeout(async () => { try { @@ -374,6 +504,92 @@ export class ExpertBlameService { return request; } + /** + * V2: Get blame case details by id from blameCases collection. + * Excludes history. Returns only non–CAR_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> { + 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; + 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) { const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000); @@ -418,6 +634,340 @@ export class ExpertBlameService { 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( expertId: string, requestId: string, diff --git a/src/expert-blame/expert-blame.v2.controller.ts b/src/expert-blame/expert-blame.v2.controller.ts new file mode 100644 index 0000000..b89afca --- /dev/null +++ b/src/expert-blame/expert-blame.v2.controller.ts @@ -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", + ); + } + } +} diff --git a/src/expert-claim/dto/claim-detail-v2.dto.ts b/src/expert-claim/dto/claim-detail-v2.dto.ts new file mode 100644 index 0000000..84f205b --- /dev/null +++ b/src/expert-claim/dto/claim-detail-v2.dto.ts @@ -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; + + @ApiPropertyOptional({ description: 'Car angles captured' }) + carAngles?: Record; + + @ApiPropertyOptional({ description: 'Damaged parts captured' }) + damagedParts?: Record; + + @ApiProperty() + createdAt: string; + + @ApiProperty() + updatedAt: string; +} diff --git a/src/expert-claim/dto/claim-list-v2.dto.ts b/src/expert-claim/dto/claim-list-v2.dto.ts new file mode 100644 index 0000000..7141e27 --- /dev/null +++ b/src/expert-claim/dto/claim-list-v2.dto.ts @@ -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; +} diff --git a/src/expert-claim/dto/expert-claim-v2.dto.ts b/src/expert-claim/dto/expert-claim-v2.dto.ts new file mode 100644 index 0000000..2b7d2c7 --- /dev/null +++ b/src/expert-claim/dto/expert-claim-v2.dto.ts @@ -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[]; +} diff --git a/src/expert-claim/expert-claim.module.ts b/src/expert-claim/expert-claim.module.ts index e24ed6c..7aa2009 100644 --- a/src/expert-claim/expert-claim.module.ts +++ b/src/expert-claim/expert-claim.module.ts @@ -15,6 +15,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { UsersModule } from "src/users/users.module"; import { ExpertClaimController } from "./expert-claim.controller"; +import { ExpertClaimV2Controller } from "./expert-claim.v2.controller"; import { ExpertClaimService } from "./expert-claim.service"; @Module({ @@ -33,7 +34,7 @@ import { ExpertClaimService } from "./expert-claim.service"; UsersModule, ClientModule, ], - controllers: [ExpertClaimController], + controllers: [ExpertClaimController, ExpertClaimV2Controller], providers: [ExpertClaimService, ClaimFactorsImageDbService], exports: [ClaimFactorsImageDbService, ExpertClaimService], }) diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 53844f3..530b936 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -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 { ClaimPerIdRs } from "./dto/claim-list-perId-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 { 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"; @@ -146,6 +152,7 @@ export class ExpertClaimService { private readonly blameVideoDbService: BlameVideoDbService, private readonly claimVideoCaptureDbService: VideoCaptureDbService, private readonly httpService: HttpService, + private readonly claimCaseDbService: ClaimCaseDbService, private readonly sandHubService: SandHubService, private readonly damageExpertDbService: DamageExpertDbService, private readonly clientDbService: ClientDbService, @@ -183,20 +190,45 @@ export class ExpertClaimService { } async getClaimRequestsListForExpert(actor): Promise { + 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( { - claimStatus: { - $in: [ - ReqClaimStatus.UnChecked, - ReqClaimStatus.ReviewRequest, - ReqClaimStatus.CheckAgain, - ReqClaimStatus.CloseRequest, - ReqClaimStatus.InPersonVisit, - ReqClaimStatus.CheckedRequest, - ReqClaimStatus.PendingFactorValidation, - ], - }, - }, + $or: [ + { + claimStatus: ReqClaimStatus.UnChecked, + }, + { + claimStatus: { + $in: [ + ReqClaimStatus.ReviewRequest, + ReqClaimStatus.CheckAgain, + ReqClaimStatus.CloseRequest, + ReqClaimStatus.InPersonVisit, + ReqClaimStatus.CheckedRequest, + ReqClaimStatus.PendingFactorValidation, + ], + }, + "damageExpertReply.actorDetail.actorId": actor.sub, + }, + ], + } ); const filteredRequests = []; @@ -1526,4 +1558,415 @@ export class ExpertClaimService { this.logger.error(error.response?.responseCode, error.response); 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 { + 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 { + 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 = {}; + 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 = {}; + 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 = {}; + 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, + }; + } } diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts new file mode 100644 index 0000000..ca146a9 --- /dev/null +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -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, + ); + } +} diff --git a/src/helpers/date-jalali.ts b/src/helpers/date-jalali.ts new file mode 100644 index 0000000..9fc6f66 --- /dev/null +++ b/src/helpers/date-jalali.ts @@ -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 = { + "۰": "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("/"); +} diff --git a/src/main.ts b/src/main.ts index a796ad0..d5379b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,8 +30,8 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle("yara724-backend") .setVersion("1.0.0") - .addServer(process.env.BASE_URL + "/api") .addServer(process.env.URL) + .addServer(process.env.BASE_URL + "/api") .addServer("http://192.168.20.249:9001") .addServer("http://192.168.20.170:9001") .addServer("http://localhost:9001") diff --git a/src/profile/dto/user/update-profile.dto.ts b/src/profile/dto/user/update-profile.dto.ts index 240f870..fd674b1 100644 --- a/src/profile/dto/user/update-profile.dto.ts +++ b/src/profile/dto/user/update-profile.dto.ts @@ -1,10 +1,6 @@ -import { ApiProperty, ApiSchema } from "@nestjs/swagger"; +import { ApiProperty } from "@nestjs/swagger"; import { IsDateString } from "class-validator"; -@ApiSchema({ - name: "Update user profile", - description: "Users updates their profile via this API.", -}) export class UpdateUserProfileDtoRq { // @ApiProperty({}) // mobile: string; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index de346d6..12c3b67 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -7,6 +7,7 @@ import { RoadCondition, LightCondition, } from "src/Types&Enums/blame-request-management/accident-conditions.enum"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; export class InitialFormDto { @ApiProperty({ required: false, default: false }) @@ -135,6 +136,28 @@ export class CreateRequestDto { 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 { // @ApiProperty() // firstPartyFile?: FirstPartyFileDto; diff --git a/src/request-management/dto/user-resend.dto.ts b/src/request-management/dto/user-resend.dto.ts new file mode 100644 index 0000000..94805b1 --- /dev/null +++ b/src/request-management/dto/user-resend.dto.ts @@ -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; +} diff --git a/src/request-management/dto/user-signature.dto.ts b/src/request-management/dto/user-signature.dto.ts new file mode 100644 index 0000000..c13349a --- /dev/null +++ b/src/request-management/dto/user-signature.dto.ts @@ -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; +} diff --git a/src/request-management/entities/db-service/blame-request.db.service.ts b/src/request-management/entities/db-service/blame-request.db.service.ts new file mode 100644 index 0000000..3c5cccf --- /dev/null +++ b/src/request-management/entities/db-service/blame-request.db.service.ts @@ -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, + ) {} + + async create(payload: Partial): Promise { + return this.blameRequestModel.create(payload); + } + + async findOne( + filter: FilterQuery, + ): Promise { + return this.blameRequestModel.findOne(filter); + } + + async findById(id: string | Types.ObjectId): Promise { + 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 | null> { + const doc = await this.blameRequestModel + .findById(id) + .select("-history") + .lean() + .exec(); + return doc as Record | null; + } + + async findByIdAndUpdate( + id: string | Types.ObjectId, + update: any, + ): Promise { + return this.blameRequestModel.findByIdAndUpdate(id, update, { new: true }); + } + + /** Atomic find and update with filter conditions (e.g. for locking). */ + async findOneAndUpdate( + filter: FilterQuery, + update: any, + options?: { new?: boolean }, + ): Promise { + return this.blameRequestModel.findOneAndUpdate(filter, update, { + new: options?.new ?? true, + }); + } + + async find( + filter: FilterQuery, + options?: { lean?: boolean; select?: string }, + ): Promise[]> { + 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[]>; + } + const query = options?.select + ? this.blameRequestModel.find(filter).select(options.select) + : this.blameRequestModel.find(filter); + return query.exec() as Promise; + } +} + diff --git a/src/request-management/entities/schema/accidentInformation.type.ts b/src/request-management/entities/schema/accidentInformation.type.ts new file mode 100644 index 0000000..fb1e03a --- /dev/null +++ b/src/request-management/entities/schema/accidentInformation.type.ts @@ -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); \ No newline at end of file diff --git a/src/request-management/entities/schema/blame-cases.schema.ts b/src/request-management/entities/schema/blame-cases.schema.ts new file mode 100644 index 0000000..c92ae5b --- /dev/null +++ b/src/request-management/entities/schema/blame-cases.schema.ts @@ -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; +export const BlameRequestSchema = SchemaFactory.createForClass(BlameRequest); \ No newline at end of file diff --git a/src/request-management/entities/schema/expert-section.type.ts b/src/request-management/entities/schema/expert-section.type.ts new file mode 100644 index 0000000..6cdb20d --- /dev/null +++ b/src/request-management/entities/schema/expert-section.type.ts @@ -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); \ No newline at end of file diff --git a/src/request-management/entities/schema/historyEvent.type.ts b/src/request-management/entities/schema/historyEvent.type.ts new file mode 100644 index 0000000..87d8653 --- /dev/null +++ b/src/request-management/entities/schema/historyEvent.type.ts @@ -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); \ No newline at end of file diff --git a/src/request-management/entities/schema/partyRole.enum.ts b/src/request-management/entities/schema/partyRole.enum.ts new file mode 100644 index 0000000..4021176 --- /dev/null +++ b/src/request-management/entities/schema/partyRole.enum.ts @@ -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); \ No newline at end of file diff --git a/src/request-management/entities/schema/request-management.schema.ts b/src/request-management/entities/schema/request-management.schema.ts index e611065..d51d977 100644 --- a/src/request-management/entities/schema/request-management.schema.ts +++ b/src/request-management/entities/schema/request-management.schema.ts @@ -368,6 +368,15 @@ export class RequestManagementModel { @Prop({ type: String, required: true, index: { unique: true } }) 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() blameStatus: ReqBlameStatus | StepsEnum; diff --git a/src/request-management/entities/schema/workflow.type.ts b/src/request-management/entities/schema/workflow.type.ts new file mode 100644 index 0000000..21be9e9 --- /dev/null +++ b/src/request-management/entities/schema/workflow.type.ts @@ -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); \ No newline at end of file diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 5c0fe8f..4e0ba63 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -6,10 +6,13 @@ import { ClientModule } from "src/client/client.module"; import { PlatesModule } from "src/plates/plates.module"; 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; import { SandHubModule } from "src/sand-hub/sand-hub.module"; import { UsersModule } from "src/users/users.module"; import { CronModule } from "src/utils/cron/cron.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 { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service"; @@ -23,12 +26,14 @@ import { BlameVoicesModel, } from "./entities/schema/blame-voice.schema"; import { BlameDocumentModel, BlameDocumentSchema } from "./entities/schema/blame-document.schema"; +import { BlameRequest, BlameRequestSchema } from "./entities/schema/blame-cases.schema"; import { RequestManagementModel, RequestManagementSchema, } from "./entities/schema/request-management.schema"; import { RequestManagementService } from "./request-management.service"; import { RequestManagementController } from "./request-management.controller"; +import { RequestManagementV2Controller } from "./request-management.v2.controller"; import { ExpertInitiatedController } from "./expert-initiated.controller"; @Module({ @@ -37,12 +42,15 @@ import { ExpertInitiatedController } from "./expert-initiated.controller"; ClientModule, SandHubModule, SmsManagerModule, + PublicIdModule, + WorkflowStepManagementModule, PlatesModule, MulterModule.register({ dest: "./files/video", }), MongooseModule.forFeature([ { name: RequestManagementModel.name, schema: RequestManagementSchema }, + { name: BlameRequest.name, schema: BlameRequestSchema }, { name: BlameVideosModel.name, schema: BlameVideoSchema }, { name: BlameVoicesModel.name, schema: BlameVoiceSchema }, { name: UserSignModel.name, schema: UserSignSchema }, @@ -51,7 +59,11 @@ import { ExpertInitiatedController } from "./expert-initiated.controller"; forwardRef(() => ClaimRequestManagementModule), CronModule, ], - controllers: [RequestManagementController, ExpertInitiatedController], + controllers: [ + RequestManagementController, + RequestManagementV2Controller, + ExpertInitiatedController, + ], providers: [ RequestManagementService, BlameVideoDbService, @@ -59,6 +71,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller"; UserSignDbService, RequestManagementDbService, BlameDocumentDbService, + BlameRequestDbService, ], exports: [ RequestManagementService, @@ -67,6 +80,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller"; BlameVoiceDbService, BlameDocumentDbService, UserSignDbService, + BlameRequestDbService, ], }) export class RequestManagementModule {} diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index de94e50..2e2d8ac 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -23,6 +23,9 @@ import { import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service"; +import { UserResendResponseDto, UserResendUploadResponseDto } from "./dto/user-resend.dto"; +import { UserSignatureResponseDto } from "./dto/user-signature.dto"; +import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum"; import { SandHubService } from "src/sand-hub/sand-hub.service"; import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -39,7 +42,6 @@ import { DetailsReplyDtoRs, UserReplyDto } from "./dto/user-reply.dto"; import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service"; import { UserSignDbService } from "./entities/db-service/sign.db.service"; import { RequestManagementModel } from "./entities/schema/request-management.schema"; -import { UploadContext } from "./entities/schema/blame-voice.schema"; import { BlameDocumentType } from "./entities/schema/blame-document.schema"; import { SandHubDbService } from "src/sand-hub/entity/db-service/sand-hub.db.service"; import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service"; @@ -53,13 +55,156 @@ import { CreateExpertInitiatedFileDto, } from "./dto/expert-initiated.dto"; import { RoleEnum } from "src/Types&Enums/role.enum"; +import { PublicIdService } from "src/utils/public-id/public-id.service"; +import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service"; +import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; +import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum"; +import { PartyRole } from "src/request-management/entities/schema/partyRole.enum"; +import { WorkflowStepDbService } from "src/workflow-step-management/entities/db-service/workflow-step.db.service"; +import { WorkflowStepModel } from "src/workflow-step-management/entities/schema/workflow-step.schema"; +import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum"; +import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum"; +import { UploadContext } from "./entities/schema/blame-voice.schema"; @Injectable() export class RequestManagementService { private readonly logger = new Logger(RequestManagementService.name); + private stepKeyToPartyRole(stepKey: WorkflowStep): PartyRole { + if (String(stepKey).startsWith("FIRST_")) return PartyRole.FIRST; + if (String(stepKey).startsWith("SECOND_")) return PartyRole.SECOND; + throw new BadRequestException( + `Step ${stepKey} is not a party-scoped step`, + ); + } + + private getPartyIndex(req: any, role: PartyRole): number { + return Array.isArray(req.parties) + ? req.parties.findIndex((p) => p?.role === role) + : -1; + } + + private assertPartyOwner(party: any, user: any, errMsg: string) { + const partyUserId = party?.person?.userId ? String(party.person.userId) : null; + const ok = + (partyUserId && partyUserId === String(user?.sub)) || + (party?.person?.phoneNumber && party.person.phoneNumber === user?.username); + if (!ok) throw new ForbiddenException(errMsg); + } + + private async advanceWorkflowToNext(req: any, submittedStepKey: WorkflowStep) { + const submittedStep = await this.getWorkflowStep({ stepKey: submittedStepKey }); + + this.logger.debug( + `[WORKFLOW] Advancing from ${submittedStepKey} (stepNumber=${submittedStep.stepNumber})`, + ); + + // completedSteps includes the step that was just submitted + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + if (!completed.includes(submittedStepKey)) completed.push(submittedStepKey); + req.workflow.completedSteps = completed; + + // Prefer sequential stepNumber, fallback to nextPossibleSteps for branching/end-of-flow + const sequentialNextStep = await this.workflowStepDbService.findByStepNumber( + submittedStep.stepNumber + 1, + ); + + const nextStepKey = (sequentialNextStep?.stepKey ?? + submittedStep.nextPossibleSteps?.[0]) as WorkflowStep | undefined; + + if (!nextStepKey) { + // Terminal state: no more steps + req.workflow.currentStep = submittedStepKey; + req.workflow.nextStep = undefined; + this.logger.debug( + `[WORKFLOW] Reached terminal state at ${submittedStepKey}`, + ); + return; + } + + // Try to get the next step doc; if it doesn't exist in DB, it's a "waiting state" (valid enum but not actionable) + let nextStepDoc: WorkflowStepModel | null = null; + try { + nextStepDoc = sequentialNextStep ?? (await this.workflowStepDbService.findByStepKey(nextStepKey)); + } catch (err) { + // Step exists in enum but not in DB → it's a waiting/terminal state + this.logger.warn( + `[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`, + ); + req.workflow.currentStep = nextStepKey; + req.workflow.nextStep = undefined; + return; + } + + if (!nextStepDoc) { + // Same case: valid enum, not in DB + this.logger.warn( + `[WORKFLOW] Step ${nextStepKey} not found in DB (waiting state). Setting as currentStep with no nextStep.`, + ); + req.workflow.currentStep = nextStepKey; + req.workflow.nextStep = undefined; + return; + } + + // For nextStep (what comes after currentStep), prefer sequential, fallback to nextPossibleSteps + const sequentialAfterNext = await this.workflowStepDbService.findByStepNumber( + nextStepDoc.stepNumber + 1, + ); + + const afterNextKey = (sequentialAfterNext?.stepKey ?? + nextStepDoc.nextPossibleSteps?.[0]) as WorkflowStep | undefined; + + req.workflow.currentStep = nextStepKey; + req.workflow.nextStep = afterNextKey; + + this.logger.debug( + `[WORKFLOW] Advanced to currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep ?? "undefined"}`, + ); + } + + private async getWorkflowStep(params: { + stepNumber?: number; + stepKey?: WorkflowStep; + }): Promise { + const { stepNumber, stepKey } = params; + + if (typeof stepNumber !== "number" && !stepKey) { + throw new BadRequestException("stepNumber or stepKey is required"); + } + + const stepByNumber = + typeof stepNumber === "number" + ? await this.workflowStepDbService.findByStepNumber(stepNumber) + : null; + + const stepByKey = stepKey + ? await this.workflowStepDbService.findByStepKey(stepKey) + : null; + + const step = stepByNumber ?? stepByKey; + if (!step) { + throw new InternalServerErrorException( + `Workflow step not found (${stepNumber ? `stepNumber=${stepNumber}` : ""}${ + stepNumber && stepKey ? ", " : "" + }${stepKey ? `stepKey=${stepKey}` : ""})`, + ); + } + + // If caller passed both, ensure they resolve to the same step. + if (stepByNumber && stepByKey && stepByNumber.stepKey !== stepByKey.stepKey) { + throw new InternalServerErrorException( + `Workflow step mismatch: stepNumber=${stepNumber} is ${stepByNumber.stepKey}, but stepKey=${stepKey} is stepNumber=${stepByKey.stepNumber}`, + ); + } + + return step; + } + constructor( private readonly requestManagementDbService: RequestManagementDbService, + private readonly blameRequestDbService: BlameRequestDbService, private readonly blameDocumentDbService: BlameDocumentDbService, private readonly blameVideoDbService: BlameVideoDbService, private readonly userDbService: UserDbService, @@ -72,10 +217,822 @@ export class RequestManagementService { private readonly expertDbService: ExpertDbService, private readonly autoCloseRequestService: AutoCloseRequestService, private readonly claimRequestManagementDbService: ClaimRequestManagementDbService, + private readonly publicIdService: PublicIdService, + private readonly workflowStepDbService: WorkflowStepDbService, ) {} + async createRequestV2(user: any, type: BlameRequestType) { + const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); + if (!firstStep?.stepKey) { + throw new InternalServerErrorException( + "Workflow stepNumber=1 not configured in step manager", + ); + } + const nextStepFromManager = firstStep.nextPossibleSteps?.[0]; + if (!nextStepFromManager) { + throw new InternalServerErrorException( + "Workflow stepNumber=1 has no nextPossibleSteps configured", + ); + } + + const publicId = await this.publicIdService.generateRequestPublicId(); + + const created = await this.blameRequestDbService.create({ + // we keep both fields for now; the shared spoken id is `publicId` + publicId, + requestNo: publicId, + type, + parties: [ + { + role: PartyRole.FIRST, + person: { + userId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + clientId: undefined, + phoneNumber: user?.username, + fullName: user?.fullName, + }, + }, + ], + workflow: { + currentStep: firstStep.stepKey as WorkflowStep, + nextStep: nextStepFromManager as WorkflowStep, + completedSteps: [firstStep.stepKey as WorkflowStep], + locked: false, + }, + history: [], + }); + + return { requestId: created._id, publicId }; + } + + async blameConfessionV2( + requestId: string, + body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean }, + user: any, + ) { + const step2 = await this.getWorkflowStep({ stepNumber: 2 }); + const step2Key = step2.stepKey as WorkflowStep; + const nextStepFromManager = step2.nextPossibleSteps?.[0]; + if (!nextStepFromManager) { + throw new InternalServerErrorException( + "Workflow stepNumber=2 has no nextPossibleSteps configured", + ); + } + + const req = await this.blameRequestDbService.findById(requestId); + if (!req) { + throw new NotFoundException("Request not found"); + } + + if (!req.workflow?.nextStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + // Validate step ordering: only allow this API when nextStep == step #2 key + if (req.workflow.nextStep !== step2Key) { + throw new BadRequestException( + `Invalid step. Expected nextStep=${step2Key} but got ${req.workflow.nextStep}`, + ); + } + + const firstPartyIndex = Array.isArray(req.parties) + ? req.parties.findIndex((p) => p?.role === PartyRole.FIRST) + : -1; + + if (firstPartyIndex === -1) { + throw new BadRequestException("First party not found on request"); + } + + const firstParty = req.parties[firstPartyIndex]; + const firstPartyUserId = firstParty?.person?.userId + ? String(firstParty.person.userId) + : null; + const isOwner = + (firstPartyUserId && firstPartyUserId === String(user?.sub)) || + (firstParty?.person?.phoneNumber && + firstParty.person.phoneNumber === user?.username); + + if (!isOwner) { + throw new ForbiddenException("Only first party can submit this step"); + } + + // Set userId for first party if not already set + if (!firstParty.person) firstParty.person = {} as any; + if (!firstParty.person.userId && user?.sub) { + firstParty.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + const imGuilty = !!body?.imGuilty; + const imDamaged = !!body?.imDamaged; + const expertOpinion = !!body?.expertOpinion; + + let blameStatus: BlameStatus; + let resolvedExpertOpinion = false; + + if (imGuilty || imDamaged) { + blameStatus = BlameStatus.AGREED; + resolvedExpertOpinion = false; + } else if (expertOpinion) { + blameStatus = BlameStatus.DISAGREEMENT; + resolvedExpertOpinion = true; + } else { + throw new BadRequestException( + "Invalid body: set imGuilty or imDamaged for AGREED, or expertOpinion for DISAGREEMENT", + ); + } + + // Persist into first party statement (new model) + if (!firstParty.statement) { + firstParty.statement = {} as any; + } + firstParty.statement.admitsGuilt = imGuilty; + firstParty.statement.claimsDamage = imDamaged; + firstParty.statement.acceptsExpertOpinion = resolvedExpertOpinion; + + // Update top-level blameStatus + req.blameStatus = blameStatus; + + // Advance workflow + // completedSteps should include the step that was just submitted (step2Key), + // but currentStep should move forward to the next step to be done. + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + if (!completed.includes(step2Key)) { + completed.push(step2Key); + } + req.workflow.completedSteps = completed; + + const nextStepKey = nextStepFromManager as WorkflowStep; // e.g. FIRST_VIDEO + const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey }); + const afterNext = nextStepDoc.nextPossibleSteps?.[0]; + if (!afterNext) { + throw new InternalServerErrorException( + `Workflow step ${nextStepKey} has no nextPossibleSteps configured`, + ); + } + + req.workflow.currentStep = nextStepKey; + req.workflow.nextStep = afterNext as WorkflowStep; // e.g. FIRST_INITIAL_FORM + + // History + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "FIRST_BLAME_CONFESSION_SUBMITTED", + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + imGuilty, + imDamaged, + expertOpinion: resolvedExpertOpinion, + blameStatus, + stepKey: step2Key, + }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + blameStatus: req.blameStatus, + workflow: req.workflow, + }; + } + + async uploadFirstPartyVideoV2( + requestId: string, + file: Express.Multer.File, + user: any, + ) { + if (!file) { + throw new BadRequestException("File is required"); + } + + const step3 = await this.getWorkflowStep({ stepNumber: 3 }); + const step3Key = step3.stepKey as WorkflowStep; // expected: FIRST_VIDEO + + const req = await this.blameRequestDbService.findById(requestId); + if (!req) { + throw new NotFoundException("Request not found"); + } + + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + // Validate step: currentStep must match stepNumber=3 (video) + if (req.workflow.currentStep !== step3Key) { + throw new BadRequestException( + `Invalid step. Expected currentStep=${step3Key} but got ${req.workflow.currentStep}`, + ); + } + + const firstPartyIndex = Array.isArray(req.parties) + ? req.parties.findIndex((p) => p?.role === PartyRole.FIRST) + : -1; + + if (firstPartyIndex === -1) { + throw new BadRequestException("First party not found on request"); + } + + const firstParty = req.parties[firstPartyIndex]; + const firstPartyUserId = firstParty?.person?.userId + ? String(firstParty.person.userId) + : null; + const isOwner = + (firstPartyUserId && firstPartyUserId === String(user?.sub)) || + (firstParty?.person?.phoneNumber && + firstParty.person.phoneNumber === user?.username); + + if (!isOwner) { + throw new ForbiddenException("Only first party can upload this video"); + } + + if (firstParty?.evidence?.videoId) { + throw new ConflictException("First party video already uploaded"); + } + + const videoDocument = await this.blameVideoDbService.create({ + fileName: file.filename, + path: file.path, + requestId: new Types.ObjectId(req._id), + } as any); + + if (!firstParty.evidence) { + firstParty.evidence = {} as any; + } + firstParty.evidence.videoId = String((videoDocument as any)._id); + + // Advance workflow + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + if (!completed.includes(step3Key)) { + completed.push(step3Key); + } + req.workflow.completedSteps = completed; + + const nextStepKey = step3.nextPossibleSteps?.[0] as WorkflowStep | undefined; + if (!nextStepKey) { + throw new InternalServerErrorException( + "Workflow stepNumber=3 has no nextPossibleSteps configured", + ); + } + const nextStepDoc = await this.getWorkflowStep({ stepKey: nextStepKey }); + const afterNext = nextStepDoc.nextPossibleSteps?.[0]; + if (!afterNext) { + throw new InternalServerErrorException( + `Workflow step ${nextStepKey} has no nextPossibleSteps configured`, + ); + } + + req.workflow.currentStep = nextStepKey; + req.workflow.nextStep = afterNext as WorkflowStep; + + // History + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "FIRST_VIDEO_UPLOADED", + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + stepKey: step3Key, + videoId: firstParty.evidence.videoId, + fileName: file.filename, + }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + videoId: firstParty.evidence.videoId, + }; + } + + async initialFormV2(requestId: string, body: AddPlateDto, user: any) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) { + throw new NotFoundException("Request not found"); + } + + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_INITIAL_FORM && + stepKey !== WorkflowStep.SECOND_INITIAL_FORM + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`, + ); + } + + // Detect which party from step + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) { + throw new BadRequestException(`${role} party not found on request`); + } + + const party = req.parties[idx]; + this.assertPartyOwner(party, user, `Only ${role} party can submit this step`); + + // Set userId for party if not already set (important for second party) + if (!party.person) party.person = {} as any; + if (!party.person.userId && user?.sub) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + // Validation: driver/insurer sameness rules + if (body.driverIsInsurer === false) { + if ( + body.nationalCodeOfInsurer === body.nationalCodeOfDriver || + body.insurerLicense === body.driverLicense + ) { + throw new BadRequestException( + "Insurer and Driver should be two different persons in this mode.", + ); + } + } else if (body.driverIsInsurer === true) { + const sameNat = body.nationalCodeOfInsurer === body.nationalCodeOfDriver; + const sameLic = body.insurerLicense === body.driverLicense; + const sameBirthday = + body.driverBirthday == null || + String(body.driverBirthday) === String(body.insurerBirthday); + if (!sameNat || !sameLic || !sameBirthday) { + throw new BadRequestException( + "When driverIsInsurer is true, insurer and driver data must be the same.", + ); + } + } + + // ---- External inquiry 1: Tejarat block inquiry ---- + let inquiryRaw: any; + let inquiryMapped: any; + try { + const inquiry = await this.sandHubService.getTejaratBlockInquiry({ + plate: body.plate, + nationalCodeOfInsurer: body.nationalCodeOfInsurer, + }); + inquiryRaw = inquiry.raw; + inquiryMapped = inquiry.mapped; + this.logger.log( + `[TEJARAT] block inquiry raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, + ); + this.logger.log( + `[TEJARAT] block inquiry mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, + ); + } catch (err: any) { + this.logger.error( + `[TEJARAT] block inquiry failed for request=${req._id}: ${err?.message || err}`, + ); + if (err?.response) { + this.logger.error( + `[TEJARAT] block inquiry response for request=${req._id}: status=${ + err.response.status + }, data=${JSON.stringify(err.response.data)}`, + ); + } + throw new HttpException( + "Inquiry failed", + HttpStatus.BAD_REQUEST, + ); + } + + if (inquiryMapped?.Error) { + this.logger.warn( + `[TEJARAT] block inquiry error for request=${req._id}: ${JSON.stringify( + inquiryMapped.Error, + )}`, + ); + throw new HttpException( + inquiryMapped.Error.Message || "Inquiry returned error", + HttpStatus.BAD_REQUEST, + ); + } + + // NOTE: personal inquiry is not part of Tejarat block inquiry flow. + + // Find client by company code + const clientName = inquiryMapped?.CompanyName; + const companyCode = inquiryMapped?.CompanyCode; + const client = await this.clientService.findClientWithCompanyCode( + +companyCode, + ); + if (!client) { + throw new HttpException("Client not found", HttpStatus.CONFLICT); + } + + // Persist inquiry/body data into new model + if (!party.person) party.person = {} as any; + const resolvedClientId = (client as any)?._id ?? (client as any)?._doc?._id; + party.person.clientId = resolvedClientId; + party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; + party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; + party.person.insurerLicense = body.insurerLicense; + party.person.driverLicense = body.driverLicense; + party.person.driverIsInsurer = body.driverIsInsurer; + party.person.isNewCar = body.isNewCar; + party.person.userNoCertificate = body.userNoCertificate; + party.person.insurerBirthday = body.insurerBirthday; + party.person.driverBirthday = + body.driverBirthday ?? (body.driverIsInsurer ? String(body.insurerBirthday) : null); + + if (!party.vehicle) party.vehicle = {} as any; + party.vehicle.isNew = body.isNewCar; + party.vehicle.plateId = body.plateId; + party.vehicle.name = inquiryMapped?.MapTypNam; + party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; + party.vehicle.inquiry = { + source: "TEJARAT_BLOCK_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }; + + if (!party.insurance) party.insurance = {} as any; + party.insurance.policyNumber = inquiryMapped?.LastCompanyDocumentNumber; + party.insurance.company = clientName; + party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; + party.insurance.startDate = inquiryMapped?.IssueDate; + party.insurance.endDate = inquiryMapped?.EndDate; + + // Advance workflow + await this.advanceWorkflowToNext(req, stepKey); + + // History + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: `${stepKey}_SUBMITTED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + role, + companyCode, + companyName: clientName, + }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + blameStatus: req.blameStatus, + status: req.status, + }; + } + + async addDetailLocationV2(requestId: string, body: LocationDto, user: any) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_LOCATION && + stepKey !== WorkflowStep.SECOND_LOCATION + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_LOCATION or SECOND_LOCATION but got ${stepKey}`, + ); + } + + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const party = req.parties[idx]; + this.assertPartyOwner(party, user, "Only the related party can submit location"); + + // Set userId for party if not already set (important for second party) + if (!party.person) party.person = {} as any; + if (!party.person.userId && user?.sub) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + party.location = body as any; + + await this.advanceWorkflowToNext(req, stepKey); + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: `${stepKey}_SUBMITTED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { location: body, role }, + } as any); + + await (req as any).save(); + + return { requestId: req._id, publicId: req.publicId, workflow: req.workflow }; + } + + async uploadVoiceV2( + requestId: string, + voice: Express.Multer.File, + user: any, + ) { + if (!voice) throw new BadRequestException("File is required"); + + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_VOICE && + stepKey !== WorkflowStep.SECOND_VOICE + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_VOICE or SECOND_VOICE but got ${stepKey}`, + ); + } + + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const party = req.parties[idx]; + this.assertPartyOwner(party, user, "Only the related party can upload voice"); + + // Set userId for party if not already set (important for second party) + if (!party.person) party.person = {} as any; + if (!party.person.userId && user?.sub) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + const voiceDoc = await this.blameVoiceDbService.create({ + fileName: voice.filename, + path: voice.path, + requestId: new Types.ObjectId(req._id), + context: UploadContext.INITIAL, + } as any); + + if (!party.evidence) party.evidence = {} as any; + if (!Array.isArray(party.evidence.voices)) party.evidence.voices = []; + party.evidence.voices.push(String((voiceDoc as any)._id)); + + await this.advanceWorkflowToNext(req, stepKey); + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: `${stepKey}_UPLOADED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + voiceId: String((voiceDoc as any)._id), + fileName: voice.filename, + role, + }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + voiceId: String((voiceDoc as any)._id), + }; + } + + async addDescriptionV2( + requestId: string, + body: DescriptionDto, + user: any, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_DESCRIPTION && + stepKey !== WorkflowStep.SECOND_DESCRIPTION + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_DESCRIPTION or SECOND_DESCRIPTION but got ${stepKey}`, + ); + } + + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) throw new BadRequestException(`${role} party not found`); + + const party = req.parties[idx]; + this.assertPartyOwner(party, user, "Only the related party can submit description"); + + // Set userId for party if not already set (important for second party) + if (!party.person) party.person = {} as any; + if (!party.person.userId && user?.sub) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + if (!party.statement) party.statement = {} as any; + party.statement.description = body.desc; + + // Optional: map accident date/time to the request-level accident info (if provided) + if (body.accidentDate || body.accidentTime) { + if (!req.accident) req.accident = {} as any; + if (body.accidentDate) req.accident.date = body.accidentDate as any; + if (body.accidentTime) req.accident.time = body.accidentTime as any; + } + + // If second party finished description, we’re ready for expert flow. + if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { + req.status = CaseStatus.WAITING_FOR_EXPERT; + } + + await this.advanceWorkflowToNext(req, stepKey); + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: `${stepKey}_SUBMITTED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { role }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + status: req.status, + }; + } + + async addSecondPartyV2( + requestId: string, + phoneNumber: string, + frontendRoute: string, + user: any, + ) { + try { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if (stepKey !== WorkflowStep.FIRST_INVITE_SECOND) { + throw new BadRequestException( + `Invalid step. Expected FIRST_INVITE_SECOND but got ${stepKey}`, + ); + } + + const firstPartyIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (firstPartyIdx === -1) { + throw new BadRequestException("First party not found"); + } + + const firstParty = req.parties[firstPartyIdx]; + this.assertPartyOwner( + firstParty, + user, + "Only first party can invite second party", + ); + + // Validate second party phone number is different + if (firstParty.person?.phoneNumber === phoneNumber) { + throw new BadRequestException( + "Second party phone number cannot be the same as first party", + ); + } + + // Check if second party already exists + const secondPartyIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondPartyIdx !== -1) { + throw new ConflictException("Second party already added to this request"); + } + + // Add SECOND party with phone number + if (!Array.isArray(req.parties)) req.parties = []; + req.parties.push({ + role: PartyRole.SECOND, + person: { + phoneNumber, + }, + } as any); + + // Update case status: now waiting for second party to complete their steps + req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; + + await this.advanceWorkflowToNext(req, stepKey); + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "SECOND_PARTY_INVITED", + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + secondPartyPhone: phoneNumber, + }, + } as any); + + await (req as any).save(); + + // Send SMS invitation + const url = `${process.env.URL}/${frontendRoute}?token=${requestId}`; + try { + await this.smsManagerService.verifyLookUp({ + token: url, + template: "yara724-invite-link", + receptor: phoneNumber, + }); + this.logger.log( + `[SMS] Invitation sent to ${phoneNumber} for request ${req.publicId}`, + ); + } catch (err) { + this.logger.error( + `[SMS] Failed to send invitation to ${phoneNumber}: ${err?.message || err}`, + ); + // Don't block the request flow if SMS fails + } + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + invitationUrl: url, + }; + } catch (err) { + this.logger.error( + `[addSecondPartyV2] Error for request ${requestId}: ${err?.message || err}`, + ); + if (err instanceof HttpException) { + throw err; + } + throw new InternalServerErrorException("Failed to add second party"); + } + } + async createRequest(user, type) { const reqNumber = new ShortUniqueId({ counter: 1 }); + const publicId = await this.publicIdService.generateRequestPublicId(); const createRequest = await this.requestManagementDbService.create({ firstPartyDetails: { firstPartyId: user.sub, @@ -88,6 +1045,7 @@ export class RequestManagementService { ? StepsEnum.F_InitialForm : StepsEnum.CarBodyForm, requestNumber: reqNumber.rnd(), + publicId, blameStatus: ReqBlameStatus.PendingForFirstParty, lockFile: false, type, @@ -2067,6 +3025,7 @@ export class RequestManagementService { ): Promise<{ requestId: string }> { const reqNumber = new ShortUniqueId({ counter: 1 }); const expertId = new Types.ObjectId(expert.sub); + const publicId = await this.publicIdService.generateRequestPublicId(); // For LINK method, validate phone numbers are provided if (dto.creationMethod === CreationMethod.LINK) { @@ -2120,6 +3079,7 @@ export class RequestManagementService { const createRequest = await this.requestManagementDbService.create({ requestNumber: reqNumber.rnd(), + publicId, type: dto.type, expertInitiated: true, initiatedBy: expertId, @@ -3209,4 +4169,362 @@ export class RequestManagementService { return new Types.ObjectId((newUser as any)._id); } + + /** + * V2: Get list of documents/items the current user needs to resend + */ + async getResendRequirementsV2( + requestId: string, + userId: string, + ): Promise { + const request = await this.blameRequestDbService.findById(requestId); + if (!request) { + throw new NotFoundException("Request not found"); + } + + // Check if status is WAITING_FOR_DOCUMENT_RESEND + if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) { + throw new BadRequestException( + "This request is not waiting for document resend", + ); + } + + // Find the current user's party + const parties = request.parties || []; + const userParty = parties.find( + (p) => String(p.person?.userId) === String(userId), + ); + + if (!userParty) { + throw new ForbiddenException( + "You are not a party in this request", + ); + } + + // Find resend request for this party (partyId matches person.userId) + const resendParties = request.expert?.resend?.parties || []; + const userResendRequest = resendParties.find( + (r) => String(r.partyId) === String(userParty.person?.userId), + ); + + if (!userResendRequest) { + throw new NotFoundException( + "No resend request found for your party. Please contact support.", + ); + } + + return { + requestId: String(request._id), + requestedItems: userResendRequest.requestedItems, + description: userResendRequest.description || "", + completed: userResendRequest.completed || false, + }; + } + + /** + * V2: Upload requested documents/evidence for resend + */ + async uploadResendDocumentsV2( + requestId: string, + userId: string, + 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[]; + }, + ): Promise { + const request = await this.blameRequestDbService.findById(requestId); + if (!request) { + throw new NotFoundException("Request not found"); + } + + // Check status + if (request.status !== CaseStatus.WAITING_FOR_DOCUMENT_RESEND) { + throw new BadRequestException( + "This request is not waiting for document resend", + ); + } + + // Find user's party + const parties = request.parties || []; + const userParty = parties.find( + (p) => String(p.person?.userId) === String(userId), + ); + + if (!userParty) { + throw new ForbiddenException("You are not a party in this request"); + } + + const partyIndex = parties.findIndex((p) => String(p.person?.userId) === userId); + if (partyIndex === -1) { + throw new ForbiddenException("Party not found in request"); + } + + // Find resend request for this party (partyId matches person.userId) + const resendParties = request.expert?.resend?.parties || []; + const resendIndex = resendParties.findIndex( + (r) => String(r.partyId) === String(userParty.person?.userId), + ); + + if (resendIndex === -1) { + throw new NotFoundException( + "No resend request found for your party", + ); + } + + const userResendRequest = resendParties[resendIndex]; + const requestedItems = userResendRequest.requestedItems || []; + + // Validate uploaded files match requested items + const uploadedItems: string[] = []; + const updatePayload: any = { $set: {} }; + + for (const fieldName in files) { + const fileArray = files[fieldName]; + if (!fileArray || fileArray.length === 0) continue; + + const file = fileArray[0]; + + // Check if this item was requested + if (!requestedItems.includes(fieldName)) { + throw new BadRequestException( + `${fieldName} was not requested. Requested items: ${requestedItems.join(", ")}`, + ); + } + + // Handle different file types + if (fieldName === "voice") { + // Create voice record + const voiceDoc = await this.blameVoiceDbService.create({ + path: file.path, + requestId: new Types.ObjectId(requestId), + fileName: file.filename, + context: "EXPERT_RESEND" as any, + }); + // Add voice to party evidence + updatePayload.$addToSet = updatePayload.$addToSet || {}; + updatePayload.$addToSet[`parties.${partyIndex}.evidence.voices`] = + (voiceDoc as any)._id; + uploadedItems.push(fieldName); + } else if (fieldName === "video") { + // Create video record + const videoDoc = await this.blameVideoDbService.create({ + path: file.path, + requestId: new Types.ObjectId(requestId), + fileName: file.filename, + }); + // Set video in party evidence + updatePayload.$set[`parties.${partyIndex}.evidence.videoId`] = + String((videoDoc as any)._id); + uploadedItems.push(fieldName); + } else { + // Document types (nationalCertificate, carCertificate, etc.) + const docType = fieldName as BlameDocumentType; + const doc = await this.blameDocumentDbService.create({ + path: file.path, + requestId: new Types.ObjectId(requestId), + fileName: file.filename, + documentType: docType, + }); + // Store document reference (can be stored in a resend-specific field or added to party documents) + // For now, store in expert.resend.parties[].uploadedDocuments + updatePayload.$set[ + `expert.resend.parties.${resendIndex}.uploadedDocuments.${fieldName}` + ] = (doc as any)._id; + uploadedItems.push(fieldName); + } + } + + if (uploadedItems.length === 0) { + throw new BadRequestException("No files were uploaded"); + } + + // Check if all requested items are now uploaded + const allItemsCompleted = requestedItems.every((item) => + uploadedItems.includes(item), + ); + + if (allItemsCompleted) { + updatePayload.$set[`expert.resend.parties.${resendIndex}.completed`] = true; + updatePayload.$set[`expert.resend.parties.${resendIndex}.completedAt`] = + new Date(); + } + + // Update the request + await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); + + // Check if all parties completed their resend + const updatedRequest = await this.blameRequestDbService.findById(requestId); + const allPartiesCompleted = updatedRequest.expert?.resend?.parties?.every( + (p) => p.completed, + ); + + if (allPartiesCompleted) { + // All parties completed - move back to WAITING_FOR_EXPERT + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + status: CaseStatus.WAITING_FOR_EXPERT, + "workflow.currentStep": "WAITING_FOR_GUILT_DECISION", + "workflow.nextStep": null, + }, + $push: { + "workflow.completedSteps": "WAITING_FOR_DOCUMENT_RESEND", + }, + }); + + this.logger.log( + `All parties completed resend for request ${requestId}. Status changed to WAITING_FOR_EXPERT`, + ); + } + + return { + message: allItemsCompleted + ? "All requested documents uploaded successfully" + : "Documents uploaded successfully. Please upload remaining items.", + uploadedItems, + allItemsCompleted, + }; + } + + /** + * V2: User signs and accepts/rejects expert decision + */ + async userSignDecisionV2( + requestId: string, + userId: string, + isAccept: boolean, + signFile: Express.Multer.File, + ): Promise { + if (!signFile) { + throw new BadRequestException("A signature file is required"); + } + + const request = await this.blameRequestDbService.findById(requestId); + if (!request) { + throw new NotFoundException("Request not found"); + } + + // Validate status is WAITING_FOR_SIGNATURES + if (request.status !== CaseStatus.WAITING_FOR_SIGNATURES) { + throw new BadRequestException( + "Request is not waiting for signatures. Current status: " + request.status, + ); + } + + // Validate expert decision exists + if (!request.expert?.decision) { + throw new BadRequestException( + "Expert has not made a decision yet", + ); + } + + // Find user's party + const parties = request.parties || []; + const partyIndex = parties.findIndex( + (p) => String(p.person?.userId) === String(userId), + ); + + if (partyIndex === -1) { + throw new ForbiddenException("You are not a party in this request"); + } + + const userParty = parties[partyIndex]; + + // Check if user already signed + if (userParty.confirmation) { + throw new BadRequestException( + "You have already signed this decision", + ); + } + + // Create signature document + const signDoc = await this.userSign.create({ + fileName: signFile.filename, + userId: userId, + path: signFile.path, + requestId: new Types.ObjectId(requestId), + }); + + const signatureData = { + fileId: String((signDoc as any)._id), + fileName: signFile.filename, + fileUrl: signFile.path, + }; + + // Update party confirmation + const updatePayload: any = { + $set: { + [`parties.${partyIndex}.confirmation`]: { + partyRole: userParty.role, + accepted: isAccept, + signature: signatureData, + }, + }, + }; + + await this.blameRequestDbService.findByIdAndUpdate(requestId, updatePayload); + + // Check if both parties have signed + const updatedRequest = await this.blameRequestDbService.findById(requestId); + const allPartiesSigned = updatedRequest.parties.every( + (p) => p.confirmation !== undefined && p.confirmation !== null, + ); + + let finalStatus = CaseStatus.WAITING_FOR_SIGNATURES; + let message = "Your signature has been recorded successfully"; + + if (allPartiesSigned) { + // Check if both accepted + const allAccepted = updatedRequest.parties.every( + (p) => p.confirmation?.accepted === true, + ); + + if (allAccepted) { + // Both parties accepted - case is completed + finalStatus = CaseStatus.COMPLETED; + message = "Both parties have accepted. Case completed successfully."; + + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + status: CaseStatus.COMPLETED, + "workflow.currentStep": "COMPLETED", + "workflow.nextStep": null, + }, + $push: { + "workflow.completedSteps": "WAITING_FOR_SIGNATURES", + }, + }); + } else { + // At least one party rejected - needs in-person resolution + finalStatus = CaseStatus.CANCELLED; // or a specific "NEEDS_IN_PERSON" status + message = + "One or both parties rejected the decision. Case requires in-person resolution."; + + await this.blameRequestDbService.findByIdAndUpdate(requestId, { + $set: { + status: CaseStatus.CANCELLED, + "workflow.currentStep": "COMPLETED", + "workflow.nextStep": null, + }, + $push: { + "workflow.completedSteps": "WAITING_FOR_SIGNATURES", + }, + }); + } + + this.logger.log( + `All parties signed for request ${requestId}. Final status: ${finalStatus}`, + ); + } + + return { + requestId: String(request._id), + accepted: isAccept, + status: finalStatus, + message, + }; + } } diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts new file mode 100644 index 0000000..2bbd3de --- /dev/null +++ b/src/request-management/request-management.v2.controller.ts @@ -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, + ); + } +} + diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index ad0a870..3cf40cb 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -20,6 +20,10 @@ export class SandHubService { private loginToken: string | 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( private readonly httpService: HttpService, private readonly sandHubDbService: SandHubDbService, @@ -62,6 +66,128 @@ export class SandHubService { } } + private async getTejaratAccessToken(): Promise { + 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) { return await this.sandHubDbService.findOneBySandHubId(sandHubId); } diff --git a/src/utils/public-id/counter.schema.ts b/src/utils/public-id/counter.schema.ts new file mode 100644 index 0000000..1131485 --- /dev/null +++ b/src/utils/public-id/counter.schema.ts @@ -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); + diff --git a/src/utils/public-id/public-id.module.ts b/src/utils/public-id/public-id.module.ts new file mode 100644 index 0000000..3499d7c --- /dev/null +++ b/src/utils/public-id/public-id.module.ts @@ -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 {} + diff --git a/src/utils/public-id/public-id.service.ts b/src/utils/public-id/public-id.service.ts new file mode 100644 index 0000000..786df12 --- /dev/null +++ b/src/utils/public-id/public-id.service.ts @@ -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, + ) {} + + /** + * 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 { + 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; + } +} + diff --git a/src/workflow-step-management/dto/claim-workflow-step.examples.ts b/src/workflow-step-management/dto/claim-workflow-step.examples.ts new file mode 100644 index 0000000..6231620 --- /dev/null +++ b/src/workflow-step-management/dto/claim-workflow-step.examples.ts @@ -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' + } + } + } +}; diff --git a/src/workflow-step-management/dto/create-claim-workflow-step.dto.ts b/src/workflow-step-management/dto/create-claim-workflow-step.dto.ts new file mode 100644 index 0000000..d5fd877 --- /dev/null +++ b/src/workflow-step-management/dto/create-claim-workflow-step.dto.ts @@ -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; + + @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[]; +} diff --git a/src/workflow-step-management/dto/create-workflow-step.dto.ts b/src/workflow-step-management/dto/create-workflow-step.dto.ts new file mode 100644 index 0000000..232c641 --- /dev/null +++ b/src/workflow-step-management/dto/create-workflow-step.dto.ts @@ -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; + + @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[]; +} diff --git a/src/workflow-step-management/dto/update-workflow-step.dto.ts b/src/workflow-step-management/dto/update-workflow-step.dto.ts new file mode 100644 index 0000000..7e702ac --- /dev/null +++ b/src/workflow-step-management/dto/update-workflow-step.dto.ts @@ -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) {} diff --git a/src/workflow-step-management/dto/workflow-step.examples.ts b/src/workflow-step-management/dto/workflow-step.examples.ts new file mode 100644 index 0000000..7c1d769 --- /dev/null +++ b/src/workflow-step-management/dto/workflow-step.examples.ts @@ -0,0 +1,1541 @@ +/** + * Swagger example bodies for workflow step management + */ + +export const createWorkflowStepExamples = { + example1: { + summary: 'Request Type Selection Step', + value: { + stepKey: 'CREATED', + type: 'THIRD_PARTY', + stepNumber: 1, + isActive: true, + stepName_fa: 'ایجاد نوع درخواست', + stepName_en: 'request_type', + description_fa: 'انتخاب نوع درخواست توسط کاربر (شخص ثالث یا بدنه)', + description_en: 'Select request type by user (THIRD_PARTY or CAR_BODY)', + category: 'INITIAL', + requiredPreviousSteps: [], + nextPossibleSteps: ['FIRST_BLAME_CONFESSION'], + allowedRoles: ['user'], + estimatedDuration: 1, + fields: [ + { + id: '507f1f77bcf86cd799439011', + name_fa: 'نوع درخواست', + name_en: 'requestType', + label_fa: 'نوع درخواست', + label_en: 'Request Type', + placeholder_fa: 'نوع درخواست خود را انتخاب کنید', + placeholder_en: 'Select your request type', + value: [ + { label_fa: 'شخص ثالث', label_en: 'Third Party', value: 'THIRD_PARTY' }, + { label_fa: 'بدنه', label_en: 'Car Body', value: 'CAR_BODY' } + ], + dataType: 'select', + required: true, + order: 1, + isActive: true, + validation: { + enum: ['THIRD_PARTY', 'CAR_BODY'] + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6' + } + } + }, + example2: { + summary: 'Blame Confession Step', + value: { + stepKey: 'FIRST_BLAME_CONFESSION', + type: 'THIRD_PARTY', + stepNumber: 2, + isActive: true, + stepName_fa: 'اعتراف طرف اول', + stepName_en: 'First Party Blame Confession', + description_fa: 'طرف اول وضعیت تقصیر و اعتراف خود را مشخص می‌کند', + description_en: 'First party declares blame status and confession', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['CREATED'], + nextPossibleSteps: ['FIRST_VIDEO'], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd799439012', + name_fa: 'وضعیت تقصیر', + name_en: 'blameStatus', + label_fa: 'وضعیت تقصیر', + label_en: 'Blame Status', + placeholder_fa: 'وضعیت تقصیر را انتخاب کنید', + placeholder_en: 'Select blame status', + value: [ + { label_fa: 'موافق', label_en: 'Agreed', value: 'AGREED' }, + { label_fa: 'مخالف', label_en: 'Disagreement', value: 'DISAGREEMENT' } + ], + dataType: 'select', + required: true, + order: 1, + isActive: true, + validation: { + enum: ['AGREED', 'DISAGREEMENT'] + } + }, + { + id: '507f1f77bcf86cd799439013', + name_fa: 'اعتراف', + name_en: 'confess', + label_fa: 'اعتراف', + label_en: 'Confession', + placeholder_fa: 'نوع اعتراف خود را انتخاب کنید', + placeholder_en: 'Select your confession type', + value: [ + { label_fa: 'مقصر', label_en: 'Guilty', value: 'guilty' }, + { label_fa: 'آسیب دیده', label_en: 'Damaged', value: 'damaged' } + ], + dataType: 'select', + required: true, + order: 2, + isActive: true, + validation: { + enum: ['guilty', 'damaged'] + } + } + ], + metadata: { + icon: 'balance-scale', + color: '#F59E0B', + requiresAuth: true + } + } + }, + example3: { + summary: 'Live Video Capture Step', + value: { + stepKey: 'FIRST_VIDEO', + type: 'THIRD_PARTY', + stepNumber: 3, + isActive: true, + stepName_fa: 'ضبط ویدیو طرف اول', + stepName_en: 'First Party Video Capture', + description_fa: 'ضبط ویدیو مستقیم از دوربین توسط طرف اول (گالری مجاز نیست)', + description_en: 'Live video capture from camera by first party (gallery not allowed)', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_BLAME_CONFESSION'], + nextPossibleSteps: ['FIRST_INITIAL_FORM'], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439014', + name_fa: 'فایل ویدیو', + name_en: 'file', + label_fa: 'ضبط ویدیو', + label_en: 'Video Recording', + placeholder_fa: 'ویدیو خود را از دوربین ضبط کنید', + placeholder_en: 'Record your video from camera', + value: null, + dataType: 'video-capture', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 52428800, + maxDuration: 180, + allowedTypes: ['video/mp4', 'video/webm'], + captureMode: 'camera-only', + allowGallery: false + } + } + ], + metadata: { + icon: 'video-camera', + color: '#EF4444', + captureType: 'live-camera', + allowGallerySelection: false, + requiresCamera: true + } + } + }, + example4: { + summary: 'Main Form Step - Driver & Insurer Details', + value: { + stepKey: 'FIRST_INITIAL_FORM', + type: 'THIRD_PARTY', + stepNumber: 4, + isActive: true, + stepName_fa: 'فرم اصلی اطلاعات راننده و بیمه‌گذار', + stepName_en: 'Main Form - Driver & Insurer Information', + description_fa: 'وارد کردن اطلاعات کامل راننده، بیمه‌گذار و خودرو', + description_en: 'Enter complete driver, insurer and vehicle information', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_VIDEO'], + nextPossibleSteps: ['FIRST_LOCATION'], + allowedRoles: ['user'], + estimatedDuration: 5, + fields: [ + { + id: '507f1f77bcf86cd799439015', + name_fa: 'کد ملی مالک', + name_en: 'nationalCodeOfOwner', + label_fa: 'کد ملی مالک', + label_en: 'National Code of Owner', + placeholder_fa: 'کد ملی ۱۰ رقمی مالک را وارد کنید', + placeholder_en: 'Enter 10-digit national code of insurer', + value: null, + dataType: 'string', + required: true, + order: 1, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439016', + name_fa: 'کد ملی راننده', + name_en: 'nationalCodeOfDriver', + label_fa: 'کد ملی راننده', + label_en: 'National Code of Driver', + placeholder_fa: 'کد ملی ۱۰ رقمی راننده را وارد کنید', + placeholder_en: 'Enter 10-digit national code of driver', + value: null, + dataType: 'string', + required: true, + order: 2, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439017', + name_fa: 'شماره گواهینامه بیمه‌گذار', + name_en: 'insurerLicense', + label_fa: 'شماره گواهینامه بیمه‌گذار', + label_en: 'Insurer License Number', + placeholder_fa: 'شماره گواهینامه بیمه‌گذار را وارد کنید', + placeholder_en: 'Enter insurer license number', + value: null, + dataType: 'string', + required: true, + order: 3, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439018', + name_fa: 'شماره گواهینامه راننده', + name_en: 'driverLicense', + label_fa: 'شماره گواهینامه راننده', + label_en: 'Driver License Number', + placeholder_fa: 'شماره گواهینامه راننده را وارد کنید', + placeholder_en: 'Enter driver license number', + value: null, + dataType: 'string', + required: true, + order: 4, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439019', + name_fa: 'پلاک خودرو', + name_en: 'plate', + label_fa: 'پلاک خودرو', + label_en: 'Vehicle Plate', + placeholder_fa: 'اطلاعات پلاک خودرو را وارد کنید', + placeholder_en: 'Enter vehicle plate information', + value: null, + dataType: 'object', + required: true, + order: 5, + isActive: true, + validation: { + type: 'object', + properties: { + leftDigits: { type: 'number', min: 10, max: 99 }, + centerAlphabet: { type: 'string', pattern: '^[آ-ی]{1}$' }, + centerDigits: { type: 'number', min: 100, max: 999 }, + ir: { type: 'number', min: 10, max: 99 } + } + } + }, + { + id: '507f1f77bcf86cd79943901a', + name_fa: 'راننده همان بیمه‌گذار است', + name_en: 'driverIsInsurer', + label_fa: 'راننده همان بیمه‌گذار است', + label_en: 'Driver is Insurer', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 6, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901b', + name_fa: 'خودرو صفر است', + name_en: 'isNewCar', + label_fa: 'خودرو صفر است', + label_en: 'Is New Car', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 7, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901c', + name_fa: 'بدون گواهی', + name_en: 'userNoCertificate', + label_fa: 'کاربر گواهی ندارد', + label_en: 'User Has No Certificate', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 8, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901d', + name_fa: 'تاریخ تولد بیمه‌گذار', + name_en: 'insurerBirthday', + label_fa: 'تاریخ تولد بیمه‌گذار', + label_en: 'Insurer Birthday', + placeholder_fa: 'تاریخ تولد بیمه‌گذار (timestamp)', + placeholder_en: 'Insurer birthday (timestamp)', + value: null, + dataType: 'number', + required: true, + order: 9, + isActive: true, + validation: { + type: 'number', + min: 0 + } + }, + { + id: '507f1f77bcf86cd79943901e', + name_fa: 'تاریخ تولد راننده', + name_en: 'driverBirthday', + label_fa: 'تاریخ تولد راننده', + label_en: 'Driver Birthday', + placeholder_fa: 'تاریخ تولد راننده را وارد کنید', + placeholder_en: 'Enter driver birthday', + value: null, + dataType: 'string', + required: true, + order: 10, + isActive: true, + validation: { + format: 'date' + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6', + formType: 'detailed', + hasComplexValidation: true + } + } + }, + example5: { + summary: 'Location Step - GPS Coordinates', + value: { + stepKey: 'FIRST_LOCATION', + type: 'THIRD_PARTY', + stepNumber: 5, + isActive: true, + stepName_fa: 'موقعیت مکانی حادثه', + stepName_en: 'Accident Location', + description_fa: 'ثبت موقعیت جغرافیایی محل وقوع حادثه', + description_en: 'Record geographical location of accident scene', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_INITIAL_FORM'], + nextPossibleSteps: ['FIRST_DESCRIPTION'], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd79943901f', + name_fa: 'عرض جغرافیایی', + name_en: 'lat', + label_fa: 'عرض جغرافیایی', + label_en: 'Latitude', + placeholder_fa: 'عرض جغرافیایی را وارد کنید', + placeholder_en: 'Enter latitude', + value: null, + dataType: 'number', + required: true, + order: 1, + isActive: true, + validation: { + type: 'number', + min: -90, + max: 90 + } + }, + { + id: '507f1f77bcf86cd799439020', + name_fa: 'طول جغرافیایی', + name_en: 'lon', + label_fa: 'طول جغرافیایی', + label_en: 'Longitude', + placeholder_fa: 'طول جغرافیایی را وارد کنید', + placeholder_en: 'Enter longitude', + value: null, + dataType: 'number', + required: true, + order: 2, + isActive: true, + validation: { + type: 'number', + min: -180, + max: 180 + } + } + ], + metadata: { + icon: 'map-pin', + color: '#10B981', + requiresGPS: true, + autoDetectLocation: true + } + } + }, + example6: { + summary: 'Voice Recording Step', + value: { + stepKey: 'FIRST_VOICE', + type: 'THIRD_PARTY', + stepNumber: 6, + isActive: true, + stepName_fa: 'ضبط صدای طرف اول', + stepName_en: 'First Party Voice Recording', + description_fa: 'ضبط توضیحات صوتی توسط طرف اول', + description_en: 'Voice recording of explanations by first party', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_LOCATION'], + nextPossibleSteps: ['FIRST_DESCRIPTION'], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439021', + name_fa: 'فایل صوتی', + name_en: 'file', + label_fa: 'ضبط صدا', + label_en: 'Voice Recording', + placeholder_fa: 'توضیحات صوتی خود را ضبط کنید', + placeholder_en: 'Record your voice explanation', + value: null, + dataType: 'audio-file', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 10485760, + maxDuration: 300, + allowedTypes: ['audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/m4a', 'audio/webm'] + } + } + ], + metadata: { + icon: 'microphone', + color: '#F59E0B', + recordingType: 'voice', + requiresMicrophone: true + } + } + }, + example7: { + summary: 'First Party Description Step', + value: { + stepKey: 'FIRST_DESCRIPTION', + type: 'THIRD_PARTY', + stepNumber: 7, + isActive: true, + stepName_fa: 'توضیحات طرف اول', + stepName_en: 'First Party Description', + description_fa: 'ثبت توضیحات و جزئیات حادثه توسط طرف اول', + description_en: 'Record accident description and details by first party', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_VOICE'], + nextPossibleSteps: ['FIRST_INVITE_SECOND'], + allowedRoles: ['user'], + estimatedDuration: 4, + fields: [ + { + id: '507f1f77bcf86cd799439022', + name_fa: 'توضیحات', + name_en: 'desc', + label_fa: 'توضیحات حادثه', + label_en: 'Accident Description', + placeholder_fa: 'توضیحات کامل حادثه را وارد کنید', + placeholder_en: 'Enter complete accident description', + value: null, + dataType: 'textarea', + required: true, + order: 1, + isActive: true, + validation: { + minLength: 10, + maxLength: 2000 + } + }, + { + id: '507f1f77bcf86cd799439023', + name_fa: 'تاریخ حادثه', + name_en: 'accidentDate', + label_fa: 'تاریخ حادثه', + label_en: 'Accident Date', + placeholder_fa: 'تاریخ وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident date', + value: null, + dataType: 'date', + required: true, + order: 2, + isActive: true, + validation: { + format: 'YYYY-MM-DD' + } + }, + { + id: '507f1f77bcf86cd799439024', + name_fa: 'زمان حادثه', + name_en: 'accidentTime', + label_fa: 'زمان حادثه', + label_en: 'Accident Time', + placeholder_fa: 'زمان وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident time', + value: null, + dataType: 'time', + required: true, + order: 3, + isActive: true, + validation: { + format: 'HH:mm' + } + } + ], + metadata: { + icon: 'file-text', + color: '#6366F1' + } + } + }, + example8: { + summary: 'Invite Second Party Step', + value: { + stepKey: 'FIRST_INVITE_SECOND', + type: 'THIRD_PARTY', + stepNumber: 8, + isActive: true, + stepName_fa: 'دعوت طرف دوم', + stepName_en: 'Invite Second Party', + description_fa: 'ارسال دعوتنامه به طرف دوم برای ثبت اطلاعات', + description_en: 'Send invitation to second party to register information', + category: 'FIRST_PARTY', + requiredPreviousSteps: ['FIRST_DESCRIPTION'], + nextPossibleSteps: ['SECOND_INITIAL_FORM'], + allowedRoles: ['user'], + estimatedDuration: 1, + fields: [], + metadata: { + icon: 'user-plus', + color: '#8B5CF6', + requiresPhoneNumber: true, + actionType: 'invitation' + } + } + }, + example9: { + summary: 'Second Party Main Form', + value: { + stepKey: 'SECOND_INITIAL_FORM', + type: 'THIRD_PARTY', + stepNumber: 9, + isActive: true, + stepName_fa: 'فرم اصلی اطلاعات طرف دوم', + stepName_en: 'Second Party Main Form', + description_fa: 'وارد کردن اطلاعات کامل راننده، بیمه‌گذار و خودرو طرف دوم', + description_en: 'Enter complete driver, insurer and vehicle information for second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: ['FIRST_INVITE_SECOND'], + nextPossibleSteps: ['SECOND_LOCATION'], + allowedRoles: ['user'], + estimatedDuration: 5, + fields: [ + { + id: '507f1f77bcf86cd799439025', + name_fa: 'کد ملی مالک', + name_en: 'nationalCodeOfOwner', + label_fa: 'کد ملی مالک', + label_en: 'National Code of Owner', + placeholder_fa: 'کد ملی ۱۰ رقمی مالک را وارد کنید', + placeholder_en: 'Enter 10-digit national code of insurer', + value: null, + dataType: 'string', + required: true, + order: 1, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439026', + name_fa: 'کد ملی راننده', + name_en: 'nationalCodeOfDriver', + label_fa: 'کد ملی راننده', + label_en: 'National Code of Driver', + placeholder_fa: 'کد ملی ۱۰ رقمی راننده را وارد کنید', + placeholder_en: 'Enter 10-digit national code of driver', + value: null, + dataType: 'string', + required: true, + order: 2, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439027', + name_fa: 'شماره گواهینامه بیمه‌گذار', + name_en: 'insurerLicense', + label_fa: 'شماره گواهینامه بیمه‌گذار', + label_en: 'Insurer License Number', + placeholder_fa: 'شماره گواهینامه بیمه‌گذار را وارد کنید', + placeholder_en: 'Enter insurer license number', + value: null, + dataType: 'string', + required: true, + order: 3, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439028', + name_fa: 'شماره گواهینامه راننده', + name_en: 'driverLicense', + label_fa: 'شماره گواهینامه راننده', + label_en: 'Driver License Number', + placeholder_fa: 'شماره گواهینامه راننده را وارد کنید', + placeholder_en: 'Enter driver license number', + value: null, + dataType: 'string', + required: true, + order: 4, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439029', + name_fa: 'پلاک خودرو', + name_en: 'plate', + label_fa: 'پلاک خودرو', + label_en: 'Vehicle Plate', + placeholder_fa: 'اطلاعات پلاک خودرو را وارد کنید', + placeholder_en: 'Enter vehicle plate information', + value: null, + dataType: 'object', + required: true, + order: 5, + isActive: true, + validation: { + type: 'object', + properties: { + leftDigits: { type: 'number', min: 10, max: 99 }, + centerAlphabet: { type: 'string', pattern: '^[آ-ی]{1}$' }, + centerDigits: { type: 'number', min: 100, max: 999 }, + ir: { type: 'number', min: 10, max: 99 } + } + } + }, + { + id: '507f1f77bcf86cd79943902a', + name_fa: 'راننده همان بیمه‌گذار است', + name_en: 'driverIsInsurer', + label_fa: 'راننده همان بیمه‌گذار است', + label_en: 'Driver is Insurer', + value: null, + dataType: 'boolean', + required: true, + order: 6, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902b', + name_fa: 'خودرو صفر است', + name_en: 'isNewCar', + label_fa: 'خودرو صفر است', + label_en: 'Is New Car', + value: null, + dataType: 'boolean', + required: true, + order: 7, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902c', + name_fa: 'بدون گواهی', + name_en: 'userNoCertificate', + label_fa: 'کاربر گواهی ندارد', + label_en: 'User Has No Certificate', + value: null, + dataType: 'boolean', + required: true, + order: 8, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902d', + name_fa: 'تاریخ تولد بیمه‌گذار', + name_en: 'insurerBirthday', + label_fa: 'تاریخ تولد بیمه‌گذار', + label_en: 'Insurer Birthday', + placeholder_fa: 'تاریخ تولد بیمه‌گذار (timestamp)', + placeholder_en: 'Insurer birthday (timestamp)', + value: null, + dataType: 'number', + required: true, + order: 9, + isActive: true, + validation: { + type: 'number', + min: 0 + } + }, + { + id: '507f1f77bcf86cd79943902e', + name_fa: 'تاریخ تولد راننده', + name_en: 'driverBirthday', + label_fa: 'تاریخ تولد راننده', + label_en: 'Driver Birthday', + placeholder_fa: 'تاریخ تولد راننده را وارد کنید', + placeholder_en: 'Enter driver birthday', + value: null, + dataType: 'string', + required: true, + order: 10, + isActive: true, + validation: { + format: 'date' + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6', + formType: 'detailed', + hasComplexValidation: true, + party: 'second' + } + } + }, + example10: { + summary: 'Second Party Location', + value: { + stepKey: 'SECOND_LOCATION', + type: 'THIRD_PARTY', + stepNumber: 10, + isActive: true, + stepName_fa: 'موقعیت مکانی طرف دوم', + stepName_en: 'Second Party Location', + description_fa: 'ثبت موقعیت جغرافیایی طرف دوم', + description_en: 'Record second party geographical location', + category: 'SECOND_PARTY', + requiredPreviousSteps: ['SECOND_INITIAL_FORM'], + nextPossibleSteps: ['SECOND_VOICE'], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd79943902f', + name_fa: 'عرض جغرافیایی', + name_en: 'lat', + label_fa: 'عرض جغرافیایی', + label_en: 'Latitude', + placeholder_fa: 'عرض جغرافیایی را وارد کنید', + placeholder_en: 'Enter latitude', + value: null, + dataType: 'number', + required: true, + order: 1, + isActive: true, + validation: { + type: 'number', + min: -90, + max: 90 + } + }, + { + id: '507f1f77bcf86cd799439030', + name_fa: 'طول جغرافیایی', + name_en: 'lon', + label_fa: 'طول جغرافیایی', + label_en: 'Longitude', + placeholder_fa: 'طول جغرافیایی را وارد کنید', + placeholder_en: 'Enter longitude', + value: null, + dataType: 'number', + required: true, + order: 2, + isActive: true, + validation: { + type: 'number', + min: -180, + max: 180 + } + } + ], + metadata: { + icon: 'map-pin', + color: '#10B981', + requiresGPS: true, + autoDetectLocation: true, + party: 'second' + } + } + }, + example11: { + summary: 'Second Party Voice Recording', + value: { + stepKey: 'SECOND_VOICE', + type: 'THIRD_PARTY', + stepNumber: 11, + isActive: true, + stepName_fa: 'ضبط صدای طرف دوم', + stepName_en: 'Second Party Voice Recording', + description_fa: 'ضبط توضیحات صوتی توسط طرف دوم', + description_en: 'Voice recording of explanations by second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: ['SECOND_LOCATION'], + nextPossibleSteps: ['SECOND_DESCRIPTION'], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439031', + name_fa: 'فایل صوتی', + name_en: 'file', + label_fa: 'ضبط صدا', + label_en: 'Voice Recording', + placeholder_fa: 'توضیحات صوتی خود را ضبط کنید', + placeholder_en: 'Record your voice explanation', + value: null, + dataType: 'audio-file', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 10485760, + maxDuration: 300, + allowedTypes: ['audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/m4a', 'audio/webm'] + } + } + ], + metadata: { + icon: 'microphone', + color: '#F59E0B', + recordingType: 'voice', + requiresMicrophone: true, + party: 'second' + } + } + }, + example12: { + summary: 'Second Party Description', + value: { + stepKey: 'SECOND_DESCRIPTION', + type: 'THIRD_PARTY', + stepNumber: 12, + isActive: true, + stepName_fa: 'توضیحات طرف دوم', + stepName_en: 'Second Party Description', + description_fa: 'ثبت توضیحات و جزئیات حادثه توسط طرف دوم', + description_en: 'Record accident description and details by second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: ['SECOND_VOICE'], + nextPossibleSteps: ['WAITING_FOR_GUILT_DECISION'], + allowedRoles: ['user'], + estimatedDuration: 4, + fields: [ + { + id: '507f1f77bcf86cd799439032', + name_fa: 'توضیحات', + name_en: 'desc', + label_fa: 'توضیحات حادثه', + label_en: 'Accident Description', + placeholder_fa: 'توضیحات کامل حادثه را وارد کنید', + placeholder_en: 'Enter complete accident description', + value: null, + dataType: 'textarea', + required: true, + order: 1, + isActive: true, + validation: { + minLength: 10, + maxLength: 2000 + } + }, + { + id: '507f1f77bcf86cd799439033', + name_fa: 'تاریخ حادثه', + name_en: 'accidentDate', + label_fa: 'تاریخ حادثه', + label_en: 'Accident Date', + placeholder_fa: 'تاریخ وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident date', + value: null, + dataType: 'date', + required: true, + order: 2, + isActive: true, + validation: { + format: 'YYYY-MM-DD' + } + }, + { + id: '507f1f77bcf86cd799439034', + name_fa: 'زمان حادثه', + name_en: 'accidentTime', + label_fa: 'زمان حادثه', + label_en: 'Accident Time', + placeholder_fa: 'زمان وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident time', + value: null, + dataType: 'time', + required: true, + order: 3, + isActive: true, + validation: { + format: 'HH:mm' + } + } + ], + metadata: { + icon: 'file-text', + color: '#6366F1', + party: 'second' + } + } + } +}; + +export const updateWorkflowStepExamples = { + example1: { + summary: 'Update step name and description', + value: { + stepName_fa: 'ویدیو طرف اول - بروزرسانی شده', + stepName_en: 'First Party Video - Updated', + description_fa: 'توضیحات بروزرسانی شده', + description_en: 'Updated description', + category: 'FIRST_PARTY', + isActive: true + } + }, + example2: { + summary: 'Update video capture field settings', + value: { + fields: [ + { + id: '507f1f77bcf86cd799439014', + name_fa: 'فایل ویدیو', + name_en: 'file', + label_fa: 'ضبط ویدیو', + label_en: 'Video Recording', + placeholder_fa: 'ویدیو خود را از دوربین ضبط کنید', + placeholder_en: 'Record your video from camera', + value: null, + dataType: 'video-capture', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 104857600, + maxDuration: 300, + allowedTypes: ['video/mp4', 'video/webm'], + captureMode: 'camera-only', + allowGallery: false + } + } + ] + } + }, + example3: { + summary: 'Update step metadata', + value: { + metadata: { + icon: 'video-camera', + color: '#10B981', + priority: 'high', + captureType: 'live-camera', + requiresCamera: true + } + } + } +}; + +export const bulkUpsertWorkflowStepExamples = { + example1: { + summary: 'Bulk upsert example', + value: [ + { + stepKey: 'FIRST_VIDEO', + type: 'THIRD_PARTY', + stepNumber: 3, + stepName_fa: 'ویدیو طرف اول', + stepName_en: 'First Party Video', + category: 'FIRST_PARTY', + isActive: true + }, + { + stepKey: 'FIRST_INITIAL_FORM', + type: 'THIRD_PARTY', + stepNumber: 4, + stepName_fa: 'فرم اولیه طرف اول', + stepName_en: 'First Party Initial Form', + category: 'FIRST_PARTY', + isActive: true + } + ] + } +}; + +export const validateAccessExamples = { + example1: { + summary: 'Validate access example', + value: { + stepKey: 'FIRST_VIDEO', + completedSteps: ['CREATED'] + } + } +}; + +// ============================================================ +// CLAIM WORKFLOW STEP EXAMPLES +// ============================================================ + +export const claimWorkflowStepExamples = { + claimCreated: { + summary: 'Claim Step 1: CLAIM_CREATED', + 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: 'Claim Step 2: SELECT_OUTER_PARTS', + 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: 'Claim Step 3: SELECT_OTHER_PARTS', + 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 insurer", + "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: 'Claim Step 4: UPLOAD_REQUIRED_DOCUMENTS', + 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" + } + } + }, + + capturePartDamages: { + summary: 'Claim Step 5: CAPTURE_PART_DAMAGES', + 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 + } + } + }, + + userSubmissionComplete: { + summary: 'Claim Step 6: 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: 'Claim Step 7: 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: 'Claim Step 8: 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: 'Claim Step 9: 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": "67fa10c259e15f231a2d1010", + "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 Step 10: 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 + } + } + } +}; diff --git a/src/workflow-step-management/entities/db-service/workflow-step.db.service.ts b/src/workflow-step-management/entities/db-service/workflow-step.db.service.ts new file mode 100644 index 0000000..0a9710a --- /dev/null +++ b/src/workflow-step-management/entities/db-service/workflow-step.db.service.ts @@ -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, + ) {} + + /** + * Create a new workflow step + */ + async create(createDto: CreateWorkflowStepDto): Promise { + const created = new this.workflowStepModel(createDto); + return await created.save(); + } + + /** + * Find all active workflow steps + */ + async findAll(): Promise { + return await this.workflowStepModel + .find({ isActive: true }) + .sort({ stepNumber: 1 }) + .exec(); + } + + /** + * Find all workflow steps including inactive ones + */ + async findAllIncludingInactive(): Promise { + return await this.workflowStepModel + .find() + .sort({ stepNumber: 1 }) + .exec(); + } + + /** + * Find a workflow step by ID + */ + async findById(id: string): Promise { + return await this.workflowStepModel.findById(id).exec(); + } + + /** + * Find a workflow step by step key (supports both blame and claim) + */ + async findByStepKey(stepKey: AnyWorkflowStep): Promise { + return await this.workflowStepModel.findOne({ stepKey }).exec(); + } + + /** + * Find a workflow step by step number + */ + async findByStepNumber(stepNumber: number): Promise { + return await this.workflowStepModel.findOne({ stepNumber }).exec(); + } + + /** + * Find steps by category + */ + async findByCategory(category: string): Promise { + 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 { + 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 { + return await this.workflowStepModel + .findOneAndUpdate({ stepKey }, updateDto, { new: true }) + .exec(); + } + + /** + * Soft delete (deactivate) a workflow step + */ + async remove(id: string): Promise { + 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 { + return await this.workflowStepModel + .findOneAndUpdate({ stepKey }, data, { upsert: true, new: true }) + .exec(); + } + + /** + * Bulk upsert workflow steps + */ + async bulkUpsert(steps: CreateWorkflowStepDto[]): Promise { + 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 { + 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 { + const count = await this.workflowStepModel.countDocuments({ stepKey }).exec(); + return count > 0; + } +} diff --git a/src/workflow-step-management/entities/schema/workflow-step.schema.ts b/src/workflow-step-management/entities/schema/workflow-step.schema.ts new file mode 100644 index 0000000..e5cf41c --- /dev/null +++ b/src/workflow-step-management/entities/schema/workflow-step.schema.ts @@ -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; // 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 diff --git a/src/workflow-step-management/index.ts b/src/workflow-step-management/index.ts new file mode 100644 index 0000000..b85e1f7 --- /dev/null +++ b/src/workflow-step-management/index.ts @@ -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'; diff --git a/src/workflow-step-management/workflow-step-management.controller.ts b/src/workflow-step-management/workflow-step-management.controller.ts new file mode 100644 index 0000000..e23cf63 --- /dev/null +++ b/src/workflow-step-management/workflow-step-management.controller.ts @@ -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); + } +} diff --git a/src/workflow-step-management/workflow-step-management.module.ts b/src/workflow-step-management/workflow-step-management.module.ts new file mode 100644 index 0000000..77f3aea --- /dev/null +++ b/src/workflow-step-management/workflow-step-management.module.ts @@ -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 {} diff --git a/src/workflow-step-management/workflow-step-management.service.ts b/src/workflow-step-management/workflow-step-management.service.ts new file mode 100644 index 0000000..7628c41 --- /dev/null +++ b/src/workflow-step-management/workflow-step-management.service.ts @@ -0,0 +1,1131 @@ +import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { WorkflowStepDbService } from './entities/db-service/workflow-step.db.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 { WorkflowStepModel } from './entities/schema/workflow-step.schema'; + +// Type alias for combined workflow steps +type AnyWorkflowStep = WorkflowStep | ClaimWorkflowStep; + +@Injectable() +export class WorkflowStepManagementService { + constructor( + private readonly workflowStepDbService: WorkflowStepDbService, + ) {} + + /** + * Create a new workflow step + */ + async create(createDto: CreateWorkflowStepDto): Promise { + // Check if step already exists + const exists = await this.workflowStepDbService.exists(createDto.stepKey); + if (exists) { + throw new ConflictException(`Workflow step with key ${createDto.stepKey} already exists`); + } + + return await this.workflowStepDbService.create(createDto); + } + + /** + * Get all active workflow steps + */ + async findAll(): Promise { + return await this.workflowStepDbService.findAll(); + } + + /** + * Get all workflow steps including inactive ones + */ + async findAllIncludingInactive(): Promise { + return await this.workflowStepDbService.findAllIncludingInactive(); + } + + /** + * Find a workflow step by ID + */ + async findById(id: string): Promise { + const step = await this.workflowStepDbService.findById(id); + if (!step) { + throw new NotFoundException(`Workflow step with ID ${id} not found`); + } + return step; + } + + /** + * Find a workflow step by step key (supports both blame and claim workflows) + */ + async findByStepKey(stepKey: AnyWorkflowStep): Promise { + const step = await this.workflowStepDbService.findByStepKey(stepKey as any); + if (!step) { + throw new NotFoundException(`Workflow step with key ${stepKey} not found`); + } + return step; + } + + /** + * Find a workflow step by stepKey or stepNumber (intelligent lookup) + */ + async findByStepKeyOrNumber(identifier: string | number): Promise { + // If identifier is a number or can be parsed as a number, search by stepNumber + const stepNumber = typeof identifier === 'number' ? identifier : Number(identifier); + + if (!isNaN(stepNumber)) { + // Search by step number + const step = await this.workflowStepDbService.findByStepNumber(stepNumber); + if (step) { + return step; + } + } + + // Otherwise, search by stepKey (supports both blame and claim) + const step = await this.workflowStepDbService.findByStepKey(identifier as any); + if (!step) { + throw new NotFoundException(`Workflow step with identifier ${identifier} not found`); + } + + return step; + } + + /** + * Find steps by category + */ + async findByCategory(category: string): Promise { + return await this.workflowStepDbService.findByCategory(category); + } + + /** + * Update a workflow step by ID + */ + async update(id: string, updateDto: UpdateWorkflowStepDto): Promise { + const updated = await this.workflowStepDbService.update(id, updateDto); + if (!updated) { + throw new NotFoundException(`Workflow step with ID ${id} not found`); + } + return updated; + } + + /** + * Update a workflow step by step key (supports both blame and claim) + */ + async updateByStepKey(stepKey: AnyWorkflowStep, updateDto: UpdateWorkflowStepDto): Promise { + const updated = await this.workflowStepDbService.updateByStepKey(stepKey as any, updateDto); + if (!updated) { + throw new NotFoundException(`Workflow step with key ${stepKey} not found`); + } + return updated; + } + + /** + * Soft delete a workflow step + */ + async remove(id: string): Promise { + const removed = await this.workflowStepDbService.remove(id); + if (!removed) { + throw new NotFoundException(`Workflow step with ID ${id} not found`); + } + return removed; + } + + /** + * Get the next possible steps from a given step (supports both blame and claim) + */ + async getNextSteps(stepKey: AnyWorkflowStep): Promise { + const currentStep = await this.findByStepKey(stepKey); + + if (!currentStep.nextPossibleSteps || currentStep.nextPossibleSteps.length === 0) { + return []; + } + + const nextSteps: WorkflowStepModel[] = []; + for (const nextStepKey of currentStep.nextPossibleSteps) { + const step = await this.workflowStepDbService.findByStepKey(nextStepKey as any); + if (step && step.isActive) { + nextSteps.push(step); + } + } + + return nextSteps; + } + + /** + * Validate if a step can be accessed based on completed steps (supports both blame and claim) + */ + async validateStepAccess( + stepKey: AnyWorkflowStep, + completedSteps: AnyWorkflowStep[], + ): Promise<{ canAccess: boolean; missingSteps: AnyWorkflowStep[] }> { + const step = await this.findByStepKey(stepKey); + + if (!step.requiredPreviousSteps || step.requiredPreviousSteps.length === 0) { + return { canAccess: true, missingSteps: [] }; + } + + const missingSteps = step.requiredPreviousSteps.filter( + (requiredStep) => !completedSteps.includes(requiredStep as any), + ); + + return { + canAccess: missingSteps.length === 0, + missingSteps, + }; + } + + /** + * Get the fields for a specific step (for dynamic form generation) + */ + async getStepFields(stepKey: AnyWorkflowStep): Promise { + const step = await this.findByStepKey(stepKey); + + return { + stepKey: step.stepKey, + stepName_fa: step.stepName_fa, + stepName_en: step.stepName_en, + description_fa: step.description_fa, + description_en: step.description_en, + fields: step.fields || [], + }; + } + + /** + * Bulk upsert workflow steps + */ + async bulkUpsert(steps: CreateWorkflowStepDto[]): Promise { + return await this.workflowStepDbService.bulkUpsert(steps); + } + + /** + * Initialize default workflow steps + */ + async initializeDefaultSteps(): Promise { + const defaultSteps: CreateWorkflowStepDto[] = [ + { + stepKey: WorkflowStep.CREATED, + type: 'THIRD_PARTY', + stepNumber: 1, + isActive: true, + stepName_fa: 'ایجاد نوع درخواست', + stepName_en: 'request_type', + description_fa: 'انتخاب نوع درخواست توسط کاربر (شخص ثالث یا بدنه)', + description_en: 'Select request type by user (THIRD_PARTY or CAR_BODY)', + category: 'INITIAL', + requiredPreviousSteps: [], + nextPossibleSteps: [WorkflowStep.FIRST_BLAME_CONFESSION], + allowedRoles: ['user'], + estimatedDuration: 1, + fields: [ + { + id: '507f1f77bcf86cd799439011' as any, + name_fa: 'نوع درخواست', + name_en: 'requestType', + label_fa: 'نوع درخواست', + label_en: 'Request Type', + placeholder_fa: 'نوع درخواست خود را انتخاب کنید', + placeholder_en: 'Select your request type', + value: [ + { label_fa: 'شخص ثالث', label_en: 'Third Party', value: 'THIRD_PARTY' }, + { label_fa: 'بدنه', label_en: 'Car Body', value: 'CAR_BODY' } + ], + dataType: 'select', + required: true, + order: 1, + isActive: true, + validation: { + enum: ['THIRD_PARTY', 'CAR_BODY'] + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6' + } + }, + { + stepKey: WorkflowStep.FIRST_BLAME_CONFESSION, + type: 'THIRD_PARTY', + stepNumber: 2, + isActive: true, + stepName_fa: 'اعتراف طرف اول', + stepName_en: 'First Party Blame Confession', + description_fa: 'طرف اول وضعیت تقصیر و اعتراف خود را مشخص می‌کند', + description_en: 'First party declares blame status and confession', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.CREATED], + nextPossibleSteps: [WorkflowStep.FIRST_VIDEO], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd799439012' as any, + name_fa: 'وضعیت تقصیر', + name_en: 'blameStatus', + label_fa: 'وضعیت تقصیر', + label_en: 'Blame Status', + placeholder_fa: 'وضعیت تقصیر را انتخاب کنید', + placeholder_en: 'Select blame status', + value: [ + { label_fa: 'موافق', label_en: 'Agreed', value: 'AGREED' }, + { label_fa: 'مخالف', label_en: 'Disagreement', value: 'DISAGREEMENT' } + ], + dataType: 'select', + required: true, + order: 1, + isActive: true, + validation: { + enum: ['AGREED', 'DISAGREEMENT'] + } + }, + { + id: '507f1f77bcf86cd799439013' as any, + name_fa: 'اعتراف', + name_en: 'confess', + label_fa: 'اعتراف', + label_en: 'Confession', + placeholder_fa: 'نوع اعتراف خود را انتخاب کنید', + placeholder_en: 'Select your confession type', + value: [ + { label_fa: 'مقصر', label_en: 'Guilty', value: 'guilty' }, + { label_fa: 'آسیب دیده', label_en: 'Damaged', value: 'damaged' } + ], + dataType: 'select', + required: true, + order: 2, + isActive: true, + validation: { + enum: ['guilty', 'damaged'] + } + } + ], + metadata: { + icon: 'balance-scale', + color: '#F59E0B', + requiresAuth: true + } + }, + { + stepKey: WorkflowStep.FIRST_VIDEO, + type: 'THIRD_PARTY', + stepNumber: 3, + isActive: true, + stepName_fa: 'ضبط ویدیو طرف اول', + stepName_en: 'First Party Video Capture', + description_fa: 'ضبط ویدیو مستقیم از دوربین توسط طرف اول (گالری مجاز نیست)', + description_en: 'Live video capture from camera by first party (gallery not allowed)', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_BLAME_CONFESSION], + nextPossibleSteps: [WorkflowStep.FIRST_INITIAL_FORM], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439014' as any, + name_fa: 'فایل ویدیو', + name_en: 'file', + label_fa: 'ضبط ویدیو', + label_en: 'Video Recording', + placeholder_fa: 'ویدیو خود را از دوربین ضبط کنید', + placeholder_en: 'Record your video from camera', + value: null, + dataType: 'video-capture', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 52428800, + maxDuration: 180, + allowedTypes: ['video/mp4', 'video/webm'], + captureMode: 'camera-only', + allowGallery: false + } + } + ], + metadata: { + icon: 'video-camera', + color: '#EF4444', + captureType: 'live-camera', + allowGallerySelection: false, + requiresCamera: true + } + }, + { + stepKey: WorkflowStep.FIRST_INITIAL_FORM, + type: 'THIRD_PARTY', + stepNumber: 4, + isActive: true, + stepName_fa: 'فرم اصلی اطلاعات راننده و بیمه‌گذار', + stepName_en: 'Main Form - Driver & Insurer Information', + description_fa: 'وارد کردن اطلاعات کامل راننده، بیمه‌گذار و خودرو', + description_en: 'Enter complete driver, insurer and vehicle information', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_VIDEO], + nextPossibleSteps: [WorkflowStep.FIRST_LOCATION], + allowedRoles: ['user'], + estimatedDuration: 5, + fields: [ + { + id: '507f1f77bcf86cd799439015' as any, + name_fa: 'کد ملی مالک', + name_en: 'nationalCodeOfOwner', + label_fa: 'کد ملی مالک', + label_en: 'National Code of Owner', + placeholder_fa: 'کد ملی ۱۰ رقمی مالک را وارد کنید', + placeholder_en: 'Enter 10-digit national code of insurer', + value: null, + dataType: 'string', + required: true, + order: 1, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439016' as any, + name_fa: 'کد ملی راننده', + name_en: 'nationalCodeOfDriver', + label_fa: 'کد ملی راننده', + label_en: 'National Code of Driver', + placeholder_fa: 'کد ملی ۱۰ رقمی راننده را وارد کنید', + placeholder_en: 'Enter 10-digit national code of driver', + value: null, + dataType: 'string', + required: true, + order: 2, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439017' as any, + name_fa: 'شماره گواهینامه بیمه‌گذار', + name_en: 'insurerLicense', + label_fa: 'شماره گواهینامه بیمه‌گذار', + label_en: 'Insurer License Number', + placeholder_fa: 'شماره گواهینامه بیمه‌گذار را وارد کنید', + placeholder_en: 'Enter insurer license number', + value: null, + dataType: 'string', + required: true, + order: 3, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439018' as any, + name_fa: 'شماره گواهینامه راننده', + name_en: 'driverLicense', + label_fa: 'شماره گواهینامه راننده', + label_en: 'Driver License Number', + placeholder_fa: 'شماره گواهینامه راننده را وارد کنید', + placeholder_en: 'Enter driver license number', + value: null, + dataType: 'string', + required: true, + order: 4, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439019' as any, + name_fa: 'پلاک خودرو', + name_en: 'plate', + label_fa: 'پلاک خودرو', + label_en: 'Vehicle Plate', + placeholder_fa: 'اطلاعات پلاک خودرو را وارد کنید', + placeholder_en: 'Enter vehicle plate information', + value: null, + dataType: 'object', + required: true, + order: 5, + isActive: true, + validation: { + type: 'object', + properties: { + leftDigits: { type: 'number', min: 10, max: 99 }, + centerAlphabet: { type: 'string', pattern: '^[آ-ی]{1}$' }, + centerDigits: { type: 'number', min: 100, max: 999 }, + ir: { type: 'number', min: 10, max: 99 } + } + } + }, + { + id: '507f1f77bcf86cd79943901a' as any, + name_fa: 'راننده همان بیمه‌گذار است', + name_en: 'driverIsInsurer', + label_fa: 'راننده همان بیمه‌گذار است', + label_en: 'Driver is Insurer', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 6, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901b' as any, + name_fa: 'خودرو صفر است', + name_en: 'isNewCar', + label_fa: 'خودرو صفر است', + label_en: 'Is New Car', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 7, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901c' as any, + name_fa: 'بدون گواهی', + name_en: 'userNoCertificate', + label_fa: 'کاربر گواهی ندارد', + label_en: 'User Has No Certificate', + placeholder_fa: '', + placeholder_en: '', + value: null, + dataType: 'boolean', + required: true, + order: 8, + isActive: true, + validation: { + type: 'boolean' + } + }, + { + id: '507f1f77bcf86cd79943901d' as any, + name_fa: 'تاریخ تولد بیمه‌گذار', + name_en: 'insurerBirthday', + label_fa: 'تاریخ تولد بیمه‌گذار', + label_en: 'Insurer Birthday', + placeholder_fa: 'تاریخ تولد بیمه‌گذار (timestamp)', + placeholder_en: 'Insurer birthday (timestamp)', + value: null, + dataType: 'number', + required: true, + order: 9, + isActive: true, + validation: { + type: 'number', + min: 0 + } + }, + { + id: '507f1f77bcf86cd79943901e' as any, + name_fa: 'تاریخ تولد راننده', + name_en: 'driverBirthday', + label_fa: 'تاریخ تولد راننده', + label_en: 'Driver Birthday', + placeholder_fa: 'تاریخ تولد راننده را وارد کنید', + placeholder_en: 'Enter driver birthday', + value: null, + dataType: 'string', + required: true, + order: 10, + isActive: true, + validation: { + format: 'date' + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6', + formType: 'detailed', + hasComplexValidation: true + } + }, + { + stepKey: WorkflowStep.FIRST_LOCATION, + type: 'THIRD_PARTY', + stepNumber: 5, + isActive: true, + stepName_fa: 'موقعیت مکانی حادثه', + stepName_en: 'Accident Location', + description_fa: 'ثبت موقعیت جغرافیایی محل وقوع حادثه', + description_en: 'Record geographical location of accident scene', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_INITIAL_FORM], + nextPossibleSteps: [WorkflowStep.FIRST_DESCRIPTION], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd79943901f' as any, + name_fa: 'عرض جغرافیایی', + name_en: 'lat', + label_fa: 'عرض جغرافیایی', + label_en: 'Latitude', + placeholder_fa: 'عرض جغرافیایی را وارد کنید', + placeholder_en: 'Enter latitude', + value: null, + dataType: 'number', + required: true, + order: 1, + isActive: true, + validation: { + type: 'number', + min: -90, + max: 90 + } + }, + { + id: '507f1f77bcf86cd799439020' as any, + name_fa: 'طول جغرافیایی', + name_en: 'lon', + label_fa: 'طول جغرافیایی', + label_en: 'Longitude', + placeholder_fa: 'طول جغرافیایی را وارد کنید', + placeholder_en: 'Enter longitude', + value: null, + dataType: 'number', + required: true, + order: 2, + isActive: true, + validation: { + type: 'number', + min: -180, + max: 180 + } + } + ], + metadata: { + icon: 'map-pin', + color: '#10B981', + requiresGPS: true, + autoDetectLocation: true + } + }, + { + stepKey: WorkflowStep.FIRST_VOICE, + type: 'THIRD_PARTY', + stepNumber: 6, + isActive: true, + stepName_fa: 'ضبط صدای طرف اول', + stepName_en: 'First Party Voice Recording', + description_fa: 'ضبط توضیحات صوتی توسط طرف اول', + description_en: 'Voice recording of explanations by first party', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_LOCATION], + nextPossibleSteps: [WorkflowStep.FIRST_DESCRIPTION], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439021' as any, + name_fa: 'فایل صوتی', + name_en: 'file', + label_fa: 'ضبط صدا', + label_en: 'Voice Recording', + placeholder_fa: 'توضیحات صوتی خود را ضبط کنید', + placeholder_en: 'Record your voice explanation', + value: null, + dataType: 'audio-file', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 10485760, + maxDuration: 300, + allowedTypes: ['audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/m4a', 'audio/webm'] + } + } + ], + metadata: { + icon: 'microphone', + color: '#F59E0B', + recordingType: 'voice', + requiresMicrophone: true + } + }, + { + stepKey: WorkflowStep.FIRST_DESCRIPTION, + type: 'THIRD_PARTY', + stepNumber: 7, + isActive: true, + stepName_fa: 'توضیحات طرف اول', + stepName_en: 'First Party Description', + description_fa: 'ثبت توضیحات و جزئیات حادثه توسط طرف اول', + description_en: 'Record accident description and details by first party', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_VOICE], + nextPossibleSteps: [WorkflowStep.FIRST_INVITE_SECOND], + allowedRoles: ['user'], + estimatedDuration: 4, + fields: [ + { + id: '507f1f77bcf86cd799439022' as any, + name_fa: 'توضیحات', + name_en: 'desc', + label_fa: 'توضیحات حادثه', + label_en: 'Accident Description', + placeholder_fa: 'توضیحات کامل حادثه را وارد کنید', + placeholder_en: 'Enter complete accident description', + value: null, + dataType: 'textarea', + required: true, + order: 1, + isActive: true, + validation: { + minLength: 10, + maxLength: 2000 + } + }, + { + id: '507f1f77bcf86cd799439023' as any, + name_fa: 'تاریخ حادثه', + name_en: 'accidentDate', + label_fa: 'تاریخ حادثه', + label_en: 'Accident Date', + placeholder_fa: 'تاریخ وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident date', + value: null, + dataType: 'date', + required: true, + order: 2, + isActive: true, + validation: { + format: 'YYYY-MM-DD' + } + }, + { + id: '507f1f77bcf86cd799439024' as any, + name_fa: 'زمان حادثه', + name_en: 'accidentTime', + label_fa: 'زمان حادثه', + label_en: 'Accident Time', + placeholder_fa: 'زمان وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident time', + value: null, + dataType: 'time', + required: true, + order: 3, + isActive: true, + validation: { + format: 'HH:mm' + } + } + ], + metadata: { + icon: 'file-text', + color: '#6366F1' + } + }, + { + stepKey: WorkflowStep.FIRST_INVITE_SECOND, + type: 'THIRD_PARTY', + stepNumber: 8, + isActive: true, + stepName_fa: 'دعوت طرف دوم', + stepName_en: 'Invite Second Party', + description_fa: 'ارسال دعوتنامه به طرف دوم برای ثبت اطلاعات', + description_en: 'Send invitation to second party to register information', + category: 'FIRST_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_DESCRIPTION], + nextPossibleSteps: [WorkflowStep.SECOND_INITIAL_FORM], + allowedRoles: ['user'], + estimatedDuration: 1, + fields: [], + metadata: { + icon: 'user-plus', + color: '#8B5CF6', + requiresPhoneNumber: true, + actionType: 'invitation' + } + }, + { + stepKey: WorkflowStep.SECOND_INITIAL_FORM, + type: 'THIRD_PARTY', + stepNumber: 9, + isActive: true, + stepName_fa: 'فرم اصلی اطلاعات طرف دوم', + stepName_en: 'Second Party Main Form', + description_fa: 'وارد کردن اطلاعات کامل راننده، بیمه‌گذار و خودرو طرف دوم', + description_en: 'Enter complete driver, insurer and vehicle information for second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: [WorkflowStep.FIRST_INVITE_SECOND], + nextPossibleSteps: [WorkflowStep.SECOND_LOCATION], + allowedRoles: ['user'], + estimatedDuration: 5, + fields: [ + { + id: '507f1f77bcf86cd799439025' as any, + name_fa: 'کد ملی مالک', + name_en: 'nationalCodeOfOwner', + label_fa: 'کد ملی مالک', + label_en: 'National Code of Owner', + placeholder_fa: 'کد ملی ۱۰ رقمی مالک را وارد کنید', + placeholder_en: 'Enter 10-digit national code of insurer', + value: null, + dataType: 'string', + required: true, + order: 1, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439026' as any, + name_fa: 'کد ملی راننده', + name_en: 'nationalCodeOfDriver', + label_fa: 'کد ملی راننده', + label_en: 'National Code of Driver', + placeholder_fa: 'کد ملی ۱۰ رقمی راننده را وارد کنید', + placeholder_en: 'Enter 10-digit national code of driver', + value: null, + dataType: 'string', + required: true, + order: 2, + isActive: true, + validation: { + pattern: '^[0-9]{10}$', + minLength: 10, + maxLength: 10 + } + }, + { + id: '507f1f77bcf86cd799439027' as any, + name_fa: 'شماره گواهینامه بیمه‌گذار', + name_en: 'insurerLicense', + label_fa: 'شماره گواهینامه بیمه‌گذار', + label_en: 'Insurer License Number', + placeholder_fa: 'شماره گواهینامه بیمه‌گذار را وارد کنید', + placeholder_en: 'Enter insurer license number', + value: null, + dataType: 'string', + required: true, + order: 3, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439028' as any, + name_fa: 'شماره گواهینامه راننده', + name_en: 'driverLicense', + label_fa: 'شماره گواهینامه راننده', + label_en: 'Driver License Number', + placeholder_fa: 'شماره گواهینامه راننده را وارد کنید', + placeholder_en: 'Enter driver license number', + value: null, + dataType: 'string', + required: true, + order: 4, + isActive: true, + validation: { + minLength: 5, + maxLength: 20 + } + }, + { + id: '507f1f77bcf86cd799439029' as any, + name_fa: 'پلاک خودرو', + name_en: 'plate', + label_fa: 'پلاک خودرو', + label_en: 'Vehicle Plate', + placeholder_fa: 'اطلاعات پلاک خودرو را وارد کنید', + placeholder_en: 'Enter vehicle plate information', + value: null, + dataType: 'object', + required: true, + order: 5, + isActive: true, + validation: { + type: 'object', + properties: { + leftDigits: { type: 'number', min: 10, max: 99 }, + centerAlphabet: { type: 'string', pattern: '^[آ-ی]{1}$' }, + centerDigits: { type: 'number', min: 100, max: 999 }, + ir: { type: 'number', min: 10, max: 99 } + } + } + }, + { + id: '507f1f77bcf86cd79943902a' as any, + name_fa: 'راننده همان بیمه‌گذار است', + name_en: 'driverIsInsurer', + label_fa: 'راننده همان بیمه‌گذار است', + label_en: 'Driver is Insurer', + value: null, + dataType: 'boolean', + required: true, + order: 6, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902b' as any, + name_fa: 'خودرو صفر است', + name_en: 'isNewCar', + label_fa: 'خودرو صفر است', + label_en: 'Is New Car', + value: null, + dataType: 'boolean', + required: true, + order: 7, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902c' as any, + name_fa: 'بدون گواهی', + name_en: 'userNoCertificate', + label_fa: 'کاربر گواهی ندارد', + label_en: 'User Has No Certificate', + value: null, + dataType: 'boolean', + required: true, + order: 8, + isActive: true + }, + { + id: '507f1f77bcf86cd79943902d' as any, + name_fa: 'تاریخ تولد بیمه‌گذار', + name_en: 'insurerBirthday', + label_fa: 'تاریخ تولد بیمه‌گذار', + label_en: 'Insurer Birthday', + placeholder_fa: 'تاریخ تولد بیمه‌گذار (timestamp)', + placeholder_en: 'Insurer birthday (timestamp)', + value: null, + dataType: 'number', + required: true, + order: 9, + isActive: true, + validation: { + type: 'number', + min: 0 + } + }, + { + id: '507f1f77bcf86cd79943902e' as any, + name_fa: 'تاریخ تولد راننده', + name_en: 'driverBirthday', + label_fa: 'تاریخ تولد راننده', + label_en: 'Driver Birthday', + placeholder_fa: 'تاریخ تولد راننده را وارد کنید', + placeholder_en: 'Enter driver birthday', + value: null, + dataType: 'string', + required: true, + order: 10, + isActive: true, + validation: { + format: 'date' + } + } + ], + metadata: { + icon: 'clipboard-list', + color: '#8B5CF6', + formType: 'detailed', + hasComplexValidation: true, + party: 'second' + } + }, + { + stepKey: WorkflowStep.SECOND_LOCATION, + type: 'THIRD_PARTY', + stepNumber: 10, + isActive: true, + stepName_fa: 'موقعیت مکانی طرف دوم', + stepName_en: 'Second Party Location', + description_fa: 'ثبت موقعیت جغرافیایی طرف دوم', + description_en: 'Record second party geographical location', + category: 'SECOND_PARTY', + requiredPreviousSteps: [WorkflowStep.SECOND_INITIAL_FORM], + nextPossibleSteps: [WorkflowStep.SECOND_VOICE], + allowedRoles: ['user'], + estimatedDuration: 2, + fields: [ + { + id: '507f1f77bcf86cd79943902f' as any, + name_fa: 'عرض جغرافیایی', + name_en: 'lat', + label_fa: 'عرض جغرافیایی', + label_en: 'Latitude', + placeholder_fa: 'عرض جغرافیایی را وارد کنید', + placeholder_en: 'Enter latitude', + value: null, + dataType: 'number', + required: true, + order: 1, + isActive: true, + validation: { + type: 'number', + min: -90, + max: 90 + } + }, + { + id: '507f1f77bcf86cd799439030' as any, + name_fa: 'طول جغرافیایی', + name_en: 'lon', + label_fa: 'طول جغرافیایی', + label_en: 'Longitude', + placeholder_fa: 'طول جغرافیایی را وارد کنید', + placeholder_en: 'Enter longitude', + value: null, + dataType: 'number', + required: true, + order: 2, + isActive: true, + validation: { + type: 'number', + min: -180, + max: 180 + } + } + ], + metadata: { + icon: 'map-pin', + color: '#10B981', + requiresGPS: true, + autoDetectLocation: true, + party: 'second' + } + }, + { + stepKey: WorkflowStep.SECOND_VOICE, + type: 'THIRD_PARTY', + stepNumber: 11, + isActive: true, + stepName_fa: 'ضبط صدای طرف دوم', + stepName_en: 'Second Party Voice Recording', + description_fa: 'ضبط توضیحات صوتی توسط طرف دوم', + description_en: 'Voice recording of explanations by second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: [WorkflowStep.SECOND_LOCATION], + nextPossibleSteps: [WorkflowStep.SECOND_DESCRIPTION], + allowedRoles: ['user'], + estimatedDuration: 3, + fields: [ + { + id: '507f1f77bcf86cd799439031' as any, + name_fa: 'فایل صوتی', + name_en: 'file', + label_fa: 'ضبط صدا', + label_en: 'Voice Recording', + placeholder_fa: 'توضیحات صوتی خود را ضبط کنید', + placeholder_en: 'Record your voice explanation', + value: null, + dataType: 'audio-file', + required: true, + order: 1, + isActive: true, + validation: { + maxSize: 10485760, + maxDuration: 300, + allowedTypes: ['audio/mp3', 'audio/mpeg', 'audio/wav', 'audio/m4a', 'audio/webm'] + } + } + ], + metadata: { + icon: 'microphone', + color: '#F59E0B', + recordingType: 'voice', + requiresMicrophone: true, + party: 'second' + } + }, + { + stepKey: WorkflowStep.SECOND_DESCRIPTION, + type: 'THIRD_PARTY', + stepNumber: 12, + isActive: true, + stepName_fa: 'توضیحات طرف دوم', + stepName_en: 'Second Party Description', + description_fa: 'ثبت توضیحات و جزئیات حادثه توسط طرف دوم', + description_en: 'Record accident description and details by second party', + category: 'SECOND_PARTY', + requiredPreviousSteps: [WorkflowStep.SECOND_VOICE], + nextPossibleSteps: [WorkflowStep.WAITING_FOR_GUILT_DECISION], + allowedRoles: ['user'], + estimatedDuration: 4, + fields: [ + { + id: '507f1f77bcf86cd799439032' as any, + name_fa: 'توضیحات', + name_en: 'desc', + label_fa: 'توضیحات حادثه', + label_en: 'Accident Description', + placeholder_fa: 'توضیحات کامل حادثه را وارد کنید', + placeholder_en: 'Enter complete accident description', + value: null, + dataType: 'textarea', + required: true, + order: 1, + isActive: true, + validation: { + minLength: 10, + maxLength: 2000 + } + }, + { + id: '507f1f77bcf86cd799439033' as any, + name_fa: 'تاریخ حادثه', + name_en: 'accidentDate', + label_fa: 'تاریخ حادثه', + label_en: 'Accident Date', + placeholder_fa: 'تاریخ وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident date', + value: null, + dataType: 'date', + required: true, + order: 2, + isActive: true, + validation: { + format: 'YYYY-MM-DD' + } + }, + { + id: '507f1f77bcf86cd799439034' as any, + name_fa: 'زمان حادثه', + name_en: 'accidentTime', + label_fa: 'زمان حادثه', + label_en: 'Accident Time', + placeholder_fa: 'زمان وقوع حادثه را وارد کنید', + placeholder_en: 'Enter accident time', + value: null, + dataType: 'time', + required: true, + order: 3, + isActive: true, + validation: { + format: 'HH:mm' + } + } + ], + metadata: { + icon: 'file-text', + color: '#6366F1', + party: 'second' + } + }, + // Add more default steps as needed... + ]; + + return await this.bulkUpsert(defaultSteps); + } +}