From 0ed7cd7012bb358dad8eb737eb265b084ac27731 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 13 Jun 2026 17:43:00 +0330 Subject: [PATCH 1/3] Fix car-damage links --- src/helpers/urlCreator.ts | 58 +++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/src/helpers/urlCreator.ts b/src/helpers/urlCreator.ts index 5d00ae7..a1cf24b 100644 --- a/src/helpers/urlCreator.ts +++ b/src/helpers/urlCreator.ts @@ -4,35 +4,59 @@ type StoredFileCapture = { }; /** - * Build a public file URL from a relative storage path (e.g. `files/...`). - * Uses the current `URL` env so deployments with custom routing stay correct. + * Reduce any stored file reference to the canonical relative storage path + * (e.g. `files/claim-captures/x.jpg`). + * + * Handles values that were persisted as absolute URLs on a different portal + * (e.g. `https://host/car-damage/user/api/files/...`) so we never leak a + * baked-in host/prefix into responses served from another origin. + */ +function toRelativeFilePath(value: string | null | undefined): string { + const s = String(value ?? "").trim(); + if (!s) return ""; + + // Prefer the `files/...` segment wherever it appears in the string. + const match = s.match(/(?:^|\/)(files\/.+)$/i); + if (match) return match[1]; + + // No recognizable file root: keep external URLs intact, otherwise strip + // any leading slashes so we can safely prefix the base URL. + if (/^https?:\/\//i.test(s)) return s; + return s.replace(/^\/+/, ""); +} + +/** + * Build a public file URL from a stored path (relative or absolute). + * + * The link is always rebuilt from the current `URL` env, so deployments with + * custom routing (different sub-paths per portal) stay correct even when the + * persisted value was created elsewhere. */ export function buildFileLink(path: string): string { - const normalized = String(path ?? "").trim(); - if (!normalized) return normalized; - if (/^https?:\/\//i.test(normalized)) { - return normalized; - } + const relative = toRelativeFilePath(path); + if (!relative) return ""; + + // Already an absolute external URL with no recognizable file root. + if (/^https?:\/\//i.test(relative)) return relative; const baseUrl = (process.env.URL ?? "").replace(/\/+$/, ""); - const relativePath = normalized.replace(/^\/+/, ""); if (process.env.NODE_ENV === "local") { - return `${baseUrl}/${relativePath}`; + return `${baseUrl}/${relative}`; } - return `${baseUrl}/api/${relativePath}`; + return `${baseUrl}/api/${relative}`; } /** * Resolve a file URL from persisted capture metadata. - * Prefer rebuilding from `path` so stale baked-in URLs (e.g. user-portal prefix) - * do not leak into expert/other API responses. + * + * Prefer `path`, fall back to `url`, and always rebuild through + * {@link buildFileLink} so stale portal-prefixed values (e.g. `.../user/api/...`) + * are normalized to the current server origin. */ export function resolveStoredFileUrl( stored?: StoredFileCapture | null, ): string | undefined { - const path = stored?.path?.trim(); - if (path) { - return buildFileLink(path); - } - return stored?.url; + const source = stored?.path?.trim() || stored?.url?.trim(); + if (!source) return undefined; + return buildFileLink(source); } From 41f81a2f76c63936039680de03ace8e940b38995 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 15 Jun 2026 11:24:41 +0330 Subject: [PATCH 2/3] Added expert field mirror flow --- .../actor/actor.auth.controller.ts | 16 +- .../claim-request-management.module.ts | 2 + .../claim-request-management.service.ts | 1 + .../entites/schema/claim-cases.schema.ts | 8 + ...xpert-initiated-claim.mirror.controller.ts | 597 +++++++++++++ src/common/auth/auth.controller.ts | 21 + src/common/auth/auth.module.ts | 30 + src/common/auth/auth.service.ts | 31 + src/common/auth/decorators/index.ts | 2 + .../auth/decorators/public.decorator.ts | 4 + src/common/auth/decorators/roles.decorator.ts | 5 + src/common/auth/dtos/index.ts | 2 + src/common/auth/dtos/user-login.dto.ts | 10 + src/common/auth/dtos/user-register.dto.ts | 7 + src/common/auth/enums/index.ts | 1 + src/common/auth/enums/roles.enum.ts | 9 + src/common/auth/guards/auth.guard.ts | 35 + src/common/auth/guards/index.ts | 2 + src/common/auth/guards/role.guard.ts | 47 ++ src/common/auth/types/index.ts | 1 + src/common/auth/types/payload.types.ts | 7 + src/expert-claim/expert-claim.service.ts | 158 +++- .../expert-claim.v2.controller.ts | 2 +- src/features/user/repositories/index.ts | 1 + .../user/repositories/user.repository.ts | 22 + src/features/user/schemas/index.ts | 3 + src/features/user/schemas/otp.schema.ts | 24 + src/features/user/schemas/profile.schema.ts | 18 + src/features/user/schemas/user.schema.ts | 22 + src/features/user/user.controller.ts | 0 src/features/user/user.module.ts | 10 + src/helpers/tenant-scope.ts | 26 + .../dto/expert-initiated.dto.ts | 18 - src/request-management/dto/party-otp.dto.ts | 45 + .../dto/send-expert-initiated-link.v2.dto.ts | 7 +- ...xpert-initiated-blame.mirror.controller.ts | 487 +++++++++++ .../expert-initiated.v2.controller.ts | 28 +- .../request-management.module.ts | 2 + .../request-management.service.ts | 781 +++++++++++++++--- 39 files changed, 2310 insertions(+), 182 deletions(-) create mode 100644 src/claim-request-management/expert-initiated-claim.mirror.controller.ts create mode 100644 src/common/auth/auth.controller.ts create mode 100644 src/common/auth/auth.module.ts create mode 100644 src/common/auth/auth.service.ts create mode 100644 src/common/auth/decorators/index.ts create mode 100644 src/common/auth/decorators/public.decorator.ts create mode 100644 src/common/auth/decorators/roles.decorator.ts create mode 100644 src/common/auth/dtos/index.ts create mode 100644 src/common/auth/dtos/user-login.dto.ts create mode 100644 src/common/auth/dtos/user-register.dto.ts create mode 100644 src/common/auth/enums/index.ts create mode 100644 src/common/auth/enums/roles.enum.ts create mode 100644 src/common/auth/guards/auth.guard.ts create mode 100644 src/common/auth/guards/index.ts create mode 100644 src/common/auth/guards/role.guard.ts create mode 100644 src/common/auth/types/index.ts create mode 100644 src/common/auth/types/payload.types.ts create mode 100644 src/features/user/repositories/index.ts create mode 100644 src/features/user/repositories/user.repository.ts create mode 100644 src/features/user/schemas/index.ts create mode 100644 src/features/user/schemas/otp.schema.ts create mode 100644 src/features/user/schemas/profile.schema.ts create mode 100644 src/features/user/schemas/user.schema.ts create mode 100644 src/features/user/user.controller.ts create mode 100644 src/features/user/user.module.ts create mode 100644 src/request-management/dto/party-otp.dto.ts create mode 100644 src/request-management/expert-initiated-blame.mirror.controller.ts diff --git a/src/auth/auth-controllers/actor/actor.auth.controller.ts b/src/auth/auth-controllers/actor/actor.auth.controller.ts index 8028e84..3141e2f 100644 --- a/src/auth/auth-controllers/actor/actor.auth.controller.ts +++ b/src/auth/auth-controllers/actor/actor.auth.controller.ts @@ -150,7 +150,7 @@ export class ActorAuthController { @ApiOperation({ summary: "Actor login (returns access + refresh tokens)", description: - "Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `[\"damage_expert\"]` — together with the actor's email/`username` and password. On success the response contains the JWT pair and the resolved profile.", + 'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with the actor\'s email/`username` and password. On success the response contains the JWT pair and the resolved profile.', }) @ApiBody({ type: LoginActorDto, @@ -159,7 +159,8 @@ export class ActorAuthController { examples: { company: { summary: "Insurer / company portal", - description: "Sample tenant credentials for the insurer (company) panel.", + description: + "Sample tenant credentials for the insurer (company) panel.", value: { role: "company", username: "saman_insurer@gmail.com", @@ -190,6 +191,17 @@ export class ActorAuthController { captcha: "a7bx2", }, }, + field_expert: { + summary: "Field expert panel", + description: "Sample credentials for a field-expert account.", + value: { + role: "field_expert", + username: "fieldexpert@gmail.com", + password: "123321", + captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + captcha: "a7bx2", + }, + }, }, }) @ApiResponse({ diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 185b21f..79e30bc 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -7,6 +7,7 @@ import { UsersModule } from "src/users/users.module"; import { ClaimRequestManagementController } from "./claim-request-management.controller"; import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller"; +import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.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"; @@ -97,6 +98,7 @@ import { HttpModule } from "@nestjs/axios"; ClaimRequestManagementController, ClaimRequestManagementV2Controller, RegistrarClaimV1Controller, + ExpertInitiatedClaimMirrorController, ], exports: [ ClaimRequestManagementService, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 28a77d0..4de9656 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -4120,6 +4120,7 @@ export class ClaimRequestManagementService { publicId: blameRequest.publicId, blameRequestId: new Types.ObjectId(blameRequestId), blameRequestNo: blameRequest.requestNo, + initiatedByFieldExpertId: new Types.ObjectId(expert.sub), status: ClaimCaseStatus.SELECTING_OUTER_PARTS, claimStatus: ClaimStatus.PENDING, inquiries: (blameRequest as any).inquiries ?? {}, diff --git a/src/claim-request-management/entites/schema/claim-cases.schema.ts b/src/claim-request-management/entites/schema/claim-cases.schema.ts index 4fb3bfd..3426f21 100644 --- a/src/claim-request-management/entites/schema/claim-cases.schema.ts +++ b/src/claim-request-management/entites/schema/claim-cases.schema.ts @@ -108,6 +108,14 @@ export class ClaimCase { @Prop({ type: String, index: true }) blameRequestNo?: string; + /** + * Set when this claim was created by a field expert on behalf of the damaged + * party (expert-initiated IN_PERSON flow). Used to scope the expert's panel + * access to only their own initiated files. + */ + @Prop({ type: Types.ObjectId, index: true }) + initiatedByFieldExpertId?: Types.ObjectId; + @Prop({ type: ClaimWorkflowSchema, default: () => ({}) }) workflow?: ClaimWorkflow; diff --git a/src/claim-request-management/expert-initiated-claim.mirror.controller.ts b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts new file mode 100644 index 0000000..7cb8c15 --- /dev/null +++ b/src/claim-request-management/expert-initiated-claim.mirror.controller.ts @@ -0,0 +1,597 @@ +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; +import { ClaimRequestManagementService } from "./claim-request-management.service"; +import { + OuterPartCatalogItemDto, + SelectOuterPartsV2Dto, + SelectOuterPartsV2ResponseDto, +} from "./dto/select-outer-parts-v2.dto"; +import { + SelectOtherPartsV2Dto, + SelectOtherPartsV2ResponseDto, +} from "./dto/select-other-parts-v2.dto"; +import { + UploadRequiredDocumentV2Dto, + UploadRequiredDocumentV2ResponseDto, +} from "./dto/upload-document-v2.dto"; +import { + CapturePartV2Dto, + CapturePartV2ResponseDto, +} from "./dto/capture-part-v2.dto"; +import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; + +/** + * Expert-initiated claim flow that mirrors the normal user claim API + * (`v2/claim-request-management`) one-to-one. The frontend reuses the same + * claim pages by only swapping the route prefix: + * + * `v2/claim-request-management/*` -> `v2/expert-initiated/claim-request-management/*` + * + * The field expert fills the claim (parts, captures, documents) on behalf of the + * damaged party for a claim created from an expert-initiated IN_PERSON blame. + * After the expert submits the data, the file follows the exact normal review + * lifecycle: the damage expert prices it, and the owner can later object / resend + * from their own account through the normal user endpoints. + */ +@ApiTags("expert-initiated claim (mirror v2)") +@Controller("v2/expert-initiated/claim-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FIELD_EXPERT) +export class ExpertInitiatedClaimMirrorController { + constructor( + private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + @Post("create-from-blame/:blameRequestId") + @ApiParam({ name: "blameRequestId" }) + @ApiOperation({ + summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame", + description: + "Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.", + }) + async createFromBlame( + @Param("blameRequestId") blameRequestId: string, + @CurrentUser() expert: any, + ) { + return this.claimRequestManagementService.createClaimFromBlameForExpertV2( + blameRequestId, + expert, + ); + } + + @Get("requests") + @ApiOperation({ summary: "[Expert mirror] List my (in-person) claims" }) + async getMyClaims( + @CurrentUser() expert: any, + @Query() query: ListQueryV2Dto, + ) { + return this.claimRequestManagementService.getMyClaimsV2( + expert.sub, + expert, + query, + ); + } + + @Get("outer-parts-catalog") + @ApiOperation({ + summary: "Get outer parts catalog (V2)", + description: + "Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.", + }) + @ApiResponse({ + status: 200, + description: "Outer parts catalog", + type: [OuterPartCatalogItemDto], + }) + async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) { + return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); + } + + @Get("car-other-part") + @ApiOperation({ + summary: "Get other (non-body) parts catalog", + description: + "Returns legacy other-parts catalog used by frontend. Response is parsed JSON.", + }) + @ApiResponse({ status: 200, description: "Other parts catalog" }) + async getCarOtherParts() { + const raw = await readFile( + `${process.cwd()}/src/static/car-part.json`, + "utf-8", + ); + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + @Get("branches/:insuranceId") + @ApiParam({ + name: "insuranceId", + description: "Insurer client id (MongoDB ObjectId)", + example: "60d5ec49e7b2f8001c8e4d2a", + }) + @ApiOperation({ + summary: "Get insurer branches (V2)", + description: + "Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.", + }) + @ApiResponse({ status: 200, description: "List of branches for insurer" }) + async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { + return this.claimRequestManagementService.retrieveInsuranceBranches( + insuranceId, + ); + } + + @Get("request/:claimRequestId") + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiOperation({ summary: "[Expert mirror] Get claim details" }) + async getClaimDetails( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() expert: any, + ) { + return this.claimRequestManagementService.getClaimDetailsV2( + claimRequestId, + expert.sub, + expert, + ); + } + + @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:** Expert selects which outer car parts (body parts) were damaged on behalf of the damaged party. + +**Validations:** +- Claim must exist +- 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 + +**After Success:** +- Workflow moves to: SELECT_OTHER_PARTS (Step 3) + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + type: SelectOuterPartsV2Dto, + description: + "Selected vehicle type + selected outer part IDs from catalog", + examples: { + example1: { + summary: "Sedan - minor front damage", + value: { + carType: "sedan", + selectedPartIds: [19, 21, 16], + }, + }, + example2: { + summary: "SUV - left side impact", + value: { + carType: "suv", + selectedPartIds: [102, 103, 104, 107], + }, + }, + example3: { + summary: "Hatchback - rear-end collision", + value: { + carType: "hatchback", + selectedPartIds: [225, 226, 210], + }, + }, + example4: { + summary: "Pickup - two sides + roof", + value: { + carType: "pickup", + selectedPartIds: [319, 312, 330], + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: "Outer parts selected successfully", + type: SelectOuterPartsV2ResponseDto, + }) + @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) + @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() expert: any, + ): Promise { + return this.claimRequestManagementService.selectOuterPartsV2( + claimRequestId, + body, + expert.sub, + expert, + ); + } + + @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:** Expert selects non-body damaged parts and provides bank information for payment on behalf of the damaged party. +Optional: upload car green card file in the same step. + +**Validations:** +- Claim must exist +- Current workflow step must be SELECT_OTHER_PARTS +- Bank information must not have been submitted previously +- Sheba (sheba) accepted as IR + 24 digits or only 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: CAPTURE_PART_DAMAGES (Step 4) + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-required-document", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `other-parts-${unique}${ex}`); + }, + }), + }), + ) + @ApiBody({ + description: + "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.", + schema: { + type: "object", + properties: { + otherParts: { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string", description: "JSON string array for multipart" }, + ], + example: ["engine", "suspension"], + }, + sheba: { type: "string", example: "IR123456789012345678901234" }, + nationalCodeOfInsurer: { type: "string", example: "1234567890" }, + file: { type: "string", format: "binary" }, + }, + required: ["sheba", "nationalCodeOfInsurer"], + }, + }) + @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: 404, description: "Claim case not found" }) + @ApiResponse({ status: 409, description: "Bank information already submitted" }) + async selectOtherParts( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOtherPartsV2Dto, + @CurrentUser() expert: any, + @UploadedFile() file?: Express.Multer.File, + ): Promise { + // Green-card photo is optional here — the helper no-ops on missing file. + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.selectOtherPartsV2( + claimRequestId, + body, + expert.sub, + expert, + file, + ); + } + + @Get("capture-requirements/:claimRequestId") + @ApiOperation({ + summary: "Get Capture Requirements (V2)", + description: ` +**Get list of what needs to be captured:** +- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item) +- Car angles (4 items: front, back, left, right) +- Damaged parts (based on selected outer parts) + +Returns status of each item (uploaded/captured or not). + +**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response. + `, + }) + @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() expert: any, + ): Promise { + return this.claimRequestManagementService.getCaptureRequirementsV2( + claimRequestId, + expert.sub, + expert, + ); + } + + @Post("upload-document/:claimRequestId") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + 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 5)", + description: ` +**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim) + +**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements): +- 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 (THIRD_PARTY) + +**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review. + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + schema: { + type: "object", + required: ["documentKey", "file"], + properties: { + documentKey: { + type: "string", + enum: [ + "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: "damaged_driving_license_front", + }, + 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() expert: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.uploadRequiredDocumentV2( + claimRequestId, + body, + file, + expert.sub, + expert, + ); + } + + @Post("capture-part/:claimRequestId") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + 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 4)", + description: ` +**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim) + +**Capture types:** +1. **angle**: Car angles (front, back, left, right) - 4 required +2. **part**: Damaged parts based on selectedParts from Step 2 + +**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured. + `, + }) + @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() expert: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.capturePartV2( + claimRequestId, + body, + file, + expert.sub, + expert, + ); + } + + @Patch("car-capture/:claimRequestId") + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/car-capture-videos/", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `claim-video-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + schema: { + type: "object", + required: ["file"], + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiOperation({ + summary: "Upload Car Walk-Around Video (V2 - Step 4b)", + description: + "Upload a walk-around video of the damaged car during the capture phase. Allowed at any point after outer parts are selected.", + }) + @ApiResponse({ status: 200, description: "Video uploaded successfully" }) + async carCapture( + @Param("claimRequestId") claimRequestId: string, + @UploadedFile("file") file: Express.Multer.File, + @CurrentUser() expert: any, + ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); + return this.claimRequestManagementService.setVideoCaptureV2( + claimRequestId, + file, + expert.sub, + expert, + ); + } +} diff --git a/src/common/auth/auth.controller.ts b/src/common/auth/auth.controller.ts new file mode 100644 index 0000000..31fa0ff --- /dev/null +++ b/src/common/auth/auth.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Post } from "@nestjs/common"; +import { Public } from "./decorators"; +import { UserLoginDto, UserRegisterDto } from "./dtos"; +import { AuthService } from "./auth.service"; + +@Controller("auth") +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post("user/register") + async register(@Body() userRegisterDto: UserRegisterDto) { + return await this.authService.registerUser(userRegisterDto); + } + + @Public() + @Post("user/login") + async login(@Body() userLoginDto: UserLoginDto) { + return await this.authService.loginUser(userLoginDto); + } +} diff --git a/src/common/auth/auth.module.ts b/src/common/auth/auth.module.ts new file mode 100644 index 0000000..fb596c5 --- /dev/null +++ b/src/common/auth/auth.module.ts @@ -0,0 +1,30 @@ +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { APP_GUARD } from "@nestjs/core"; +import { JwtModule } from "@nestjs/jwt"; +import { StringValue } from "ms"; +import { AuthGuard, RolesGuard } from "./guards"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; + +@Module({ + imports: [ + JwtModule.registerAsync({ + global: true, + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get("JWT_SECRET"), + signOptions: { + expiresIn: configService.get("JWT_EXPIRY"), + }, + }), + }), + ], + controllers: [AuthController], + providers: [ + { provide: APP_GUARD, useClass: AuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + AuthService, + ], +}) +export class AuthModule {} diff --git a/src/common/auth/auth.service.ts b/src/common/auth/auth.service.ts new file mode 100644 index 0000000..3b8d0c5 --- /dev/null +++ b/src/common/auth/auth.service.ts @@ -0,0 +1,31 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { UserRepository } from "src/features/user/repositories"; +import { UserLoginDto, UserRegisterDto } from "./dtos"; + +@Injectable() +export class AuthService { + constructor(private readonly userRepo: UserRepository) {} + + async registerUser(userRegisterDto: UserRegisterDto) { + const user = await this.userRepo.retrieveByCellPhoneNumber( + userRegisterDto.cellphoneNumber, + ); + + if (!user) { + // Create user + Send OTP and update database + } + + // Send OTP to the cellphone number of user and update database + // TODO: Create a mock otp for once we got no otp senders or development phase + } + + async loginUser(userLoginDto: UserLoginDto) { + const user = await this.userRepo.retrieveByCellPhoneNumber( + userLoginDto.cellphoneNumber, + ); + + if (!user) throw new BadRequestException("User does not exist"); + + // Generate token (access + refresh) and save inside the database, return them to the user + } +} diff --git a/src/common/auth/decorators/index.ts b/src/common/auth/decorators/index.ts new file mode 100644 index 0000000..27d525d --- /dev/null +++ b/src/common/auth/decorators/index.ts @@ -0,0 +1,2 @@ +export { IS_PUBLIC_KEY, Public } from "./public.decorator"; +export { ROLES_KEY, Roles } from "./roles.decorator"; diff --git a/src/common/auth/decorators/public.decorator.ts b/src/common/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..7beff6f --- /dev/null +++ b/src/common/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/auth/decorators/roles.decorator.ts b/src/common/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..8c7ccc3 --- /dev/null +++ b/src/common/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; +import { Role } from "../enums/roles.enum"; + +export const ROLES_KEY = "roles"; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/auth/dtos/index.ts b/src/common/auth/dtos/index.ts new file mode 100644 index 0000000..56b9e5a --- /dev/null +++ b/src/common/auth/dtos/index.ts @@ -0,0 +1,2 @@ +export { UserRegisterDto } from "./user-register.dto"; +export { UserLoginDto } from "./user-login.dto"; diff --git a/src/common/auth/dtos/user-login.dto.ts b/src/common/auth/dtos/user-login.dto.ts new file mode 100644 index 0000000..810a9c1 --- /dev/null +++ b/src/common/auth/dtos/user-login.dto.ts @@ -0,0 +1,10 @@ +import { IsMobilePhone, IsNumberString, IsString } from "class-validator"; + +export class UserLoginDto { + @IsString({ message: "Cellphone number must be string" }) + @IsMobilePhone("fa-IR") + cellphoneNumber: string; + + @IsNumberString() + otp: string; +} diff --git a/src/common/auth/dtos/user-register.dto.ts b/src/common/auth/dtos/user-register.dto.ts new file mode 100644 index 0000000..5ee465e --- /dev/null +++ b/src/common/auth/dtos/user-register.dto.ts @@ -0,0 +1,7 @@ +import { IsMobilePhone, IsNumberString, IsString } from "class-validator"; + +export class UserRegisterDto { + @IsString({ message: "Cellphone number must be string" }) + @IsMobilePhone("fa-IR") + cellphoneNumber: string; +} diff --git a/src/common/auth/enums/index.ts b/src/common/auth/enums/index.ts new file mode 100644 index 0000000..5261488 --- /dev/null +++ b/src/common/auth/enums/index.ts @@ -0,0 +1 @@ +export { Role } from "./roles.enum"; diff --git a/src/common/auth/enums/roles.enum.ts b/src/common/auth/enums/roles.enum.ts new file mode 100644 index 0000000..0f9b316 --- /dev/null +++ b/src/common/auth/enums/roles.enum.ts @@ -0,0 +1,9 @@ +export enum Role { + SUPER_ADMIN = "super_admin", + INSURER = "insurer", + ACCIDENT_EXPERT = "accident_expert", + CLAIM_EXPERT = "claim_expert", + FIELD_EXPERT = "field_expert", + REGISTRAR = "registrar", + USER = "user", +} diff --git a/src/common/auth/guards/auth.guard.ts b/src/common/auth/guards/auth.guard.ts new file mode 100644 index 0000000..d6fdd28 --- /dev/null +++ b/src/common/auth/guards/auth.guard.ts @@ -0,0 +1,35 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { Request } from "express"; +import { JwtPayload } from "../types/payload.types"; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException("No token provided"); + } + try { + const payload: JwtPayload = + await this.jwtService.verifyAsync(token); + request["user"] = payload; + } catch { + throw new UnauthorizedException("Invalid or expired token"); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(" ") ?? []; + return type === "Bearer" ? token : undefined; + } +} diff --git a/src/common/auth/guards/index.ts b/src/common/auth/guards/index.ts new file mode 100644 index 0000000..0ee772d --- /dev/null +++ b/src/common/auth/guards/index.ts @@ -0,0 +1,2 @@ +export { AuthGuard } from "./auth.guard"; +export { RolesGuard } from "./role.guard"; diff --git a/src/common/auth/guards/role.guard.ts b/src/common/auth/guards/role.guard.ts new file mode 100644 index 0000000..d5eaf88 --- /dev/null +++ b/src/common/auth/guards/role.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Request } from "express"; +import { IS_PUBLIC_KEY, ROLES_KEY } from "../decorators"; +import { Role } from "../enums"; +import { JwtPayload } from "../types"; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) return true; + + const request = context.switchToHttp().getRequest(); + const user: JwtPayload = request["user"] as JwtPayload | undefined; + + if (!user?.role) { + throw new ForbiddenException("No role found in token"); + } + + const hasRole = requiredRoles.includes(user.role as Role); + if (!hasRole) { + throw new ForbiddenException( + `Access denied. Required: [${requiredRoles.join(", ")}]. Your role: ${user.role}`, + ); + } + + return true; + } +} diff --git a/src/common/auth/types/index.ts b/src/common/auth/types/index.ts new file mode 100644 index 0000000..5af126d --- /dev/null +++ b/src/common/auth/types/index.ts @@ -0,0 +1 @@ +export { JwtPayload } from "./payload.types"; diff --git a/src/common/auth/types/payload.types.ts b/src/common/auth/types/payload.types.ts new file mode 100644 index 0000000..a939f9b --- /dev/null +++ b/src/common/auth/types/payload.types.ts @@ -0,0 +1,7 @@ +export interface JwtPayload { + sub: string; + role: string; + clientId?: string; + iat?: number; + exp?: number; +} diff --git a/src/expert-claim/expert-claim.service.ts b/src/expert-claim/expert-claim.service.ts index 6fa6dd7..594a80c 100644 --- a/src/expert-claim/expert-claim.service.ts +++ b/src/expert-claim/expert-claim.service.ts @@ -46,8 +46,11 @@ 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 { RoleEnum } from "src/Types&Enums/role.enum"; import { assertClaimCaseForTenant, + assertClaimCaseForExpertActor, + claimCaseInitiatedByFieldExpert, claimCaseTouchesClient, requireActorClientKey, } from "src/helpers/tenant-scope"; @@ -2148,7 +2151,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub); @@ -2389,9 +2392,9 @@ export class ExpertClaimService { */ async assignClaimForReviewV2( claimRequestId: string, - actor: { sub: string; fullName?: string; clientKey?: string }, + actor: { sub: string; fullName?: string; clientKey?: string; role?: string }, ): Promise { - requireActorClientKey(actor); + if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -2399,7 +2402,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); const isFactorValidationLock = claimIsAwaitingExpertFactorValidationV2(claim); @@ -2692,7 +2695,7 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( @@ -2850,14 +2853,14 @@ export class ExpertClaimService { reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto, actor: any, ) { - requireActorClientKey(actor); + if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( @@ -3218,6 +3221,28 @@ export class ExpertClaimService { * Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file. */ async getStatusReportBucketsV2(actor: any): Promise> { + if (actor.role === RoleEnum.FIELD_EXPERT) { + const rows = (await this.claimCaseDbService.find( + { initiatedByFieldExpertId: new Types.ObjectId(actor.sub) }, + { lean: true }, + )) as any[]; + const buckets = initialClaimExpertReportBuckets(); + for (const doc of rows) { + const key = claimCaseStatusToReportBucket(String(doc.status ?? "")); + buckets.all++; + buckets[key] = (buckets[key] ?? 0) + 1; + } + buckets["PENDING_ON_USER"] = + (buckets[ClaimCaseStatus.WAITING_FOR_USER_RESEND] ?? 0) + + (buckets[ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL] ?? 0) + + (buckets[ClaimCaseStatus.INSURER_REVIEW_AWAITING_OWNER_SIGN] ?? 0) + + (buckets[ClaimCaseStatus.INSURER_REVIEW_MIXED_FACTORS_PENDING] ?? 0) + + (buckets[ClaimCaseStatus.OWNER_REPAIR_FACTOR_UPLOAD_PENDING] ?? 0); + buckets["CHECK_NEEDED"] = + buckets["PENDING_ON_USER"] + + (buckets[ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT] ?? 0); + return buckets; + } const clientKey = requireActorClientKey(actor); const expertId = String(actor.sub); const expertOid = new Types.ObjectId(expertId); @@ -3290,6 +3315,9 @@ export class ExpertClaimService { actor: any, query: ListQueryV2Dto = {}, ): Promise { + if (actor.role === RoleEnum.FIELD_EXPERT) { + return this.getFieldExpertClaimListV2(actor, query); + } requireActorClientKey(actor); const actorId = actor.sub; const clientKey = actor.clientKey as string; @@ -3493,6 +3521,96 @@ export class ExpertClaimService { }; } + /** + * Claim list for a FIELD_EXPERT — returns only the claims they personally + * initiated (expert-initiated IN_PERSON blame → createClaimFromBlameForExpertV2). + */ + private async getFieldExpertClaimListV2( + actor: any, + query: ListQueryV2Dto = {}, + ): Promise { + const claims = (await this.claimCaseDbService.find({ + initiatedByFieldExpertId: new Types.ObjectId(actor.sub), + })) as any[]; + + const blameIds = [ + ...new Set( + claims + .map((c) => c.blameRequestId?.toString()) + .filter((id): id is string => !!id), + ), + ]; + const blames = + blameIds.length > 0 + ? ((await this.blameRequestDbService.find( + { _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } }, + { lean: true, select: "type parties expert.decision blameStatus" }, + )) as any[]) + : []; + const blameById = new Map( + blames.map((b) => [String(b._id), b]), + ); + + const list = claims.map((c) => { + const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById); + const blame = c.blameRequestId + ? blameById.get(c.blameRequestId.toString()) + : undefined; + const fileCtx = blame ? this.blameFileContextForExpert(blame) : {}; + const lockActive = !!( + c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c) + ); + return { + claimRequestId: c._id.toString(), + publicId: c.publicId, + status: c.status, + currentStep: c.workflow?.currentStep || "", + locked: lockActive, + lockedBy: + lockActive && c.workflow?.lockedBy + ? { + actorId: c.workflow.lockedBy.actorId?.toString(), + actorName: c.workflow.lockedBy.actorName, + lockedAt: (c.workflow as any).lockedAt?.toISOString?.(), + expiredAt: (c.workflow as any).expiredAt?.toISOString?.(), + } + : undefined, + vehicle: v + ? { carName: v.carName, carModel: v.carModel, carType: v.carType } + : undefined, + ...fileCtx, + createdAt: c.createdAt, + awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c), + }; + }) as ClaimListItemV2Dto[]; + + const paged = applyListQueryV2( + list, + { + publicId: (r) => r.publicId, + createdAt: (r) => r.createdAt, + requestNo: (r) => r.publicId, + status: (r) => r.status, + searchExtras: (r) => + [ + r.claimRequestId, + r.currentStep, + r.vehicle?.carName, + r.vehicle?.carModel, + ].filter(Boolean) as string[], + }, + query, + ); + + return { + list: paged.list, + total: paged.total, + page: paged.page, + limit: paged.limit, + totalPages: paged.totalPages, + }; + } + /** * Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`: * party evidence `videoUrl` / `voiceUrls`, Jalali date strings). @@ -3668,7 +3786,9 @@ export class ExpertClaimService { private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: { sub: string; clientKey?: string; + role?: string; }): Promise { + if ((actor as any).role === RoleEnum.FIELD_EXPERT) return; const clientKey = requireActorClientKey(actor); const lockTtlMs = this.claimV2WorkflowLockTtlMs; const now = new Date(); @@ -3828,7 +3948,9 @@ export class ExpertClaimService { actor: any, ): Promise { const actorId = actor.sub; - await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); + if (actor.role !== RoleEnum.FIELD_EXPERT) { + await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); + } await this.expireClaimWorkflowLockV2IfStale(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId); @@ -3836,13 +3958,22 @@ export class ExpertClaimService { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); + // Variables used both in the gate block and in the detail-build section below const isDamageExpertPhase = claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT || claim.status === ClaimCaseStatus.EXPERT_REVIEWING; const isFactorValidationPending = claimIsAwaitingExpertFactorValidationV2(claim); + + // Field experts can always view their own initiated claims regardless of status + if (actor.role === RoleEnum.FIELD_EXPERT) { + if (!claimCaseInitiatedByFieldExpert(claim, actor)) { + throw new ForbiddenException("This claim was not initiated by you."); + } + // Fall through to the detail build below (skip the damage-expert gate) + } else { const isResendPending = claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND; @@ -3891,6 +4022,7 @@ export class ExpertClaimService { "You do not have permission to view this claim.", ); } + } // end damage-expert gate (else block) // Build requiredDocuments map const requiredDocs = claim.requiredDocuments as any; @@ -4134,8 +4266,8 @@ export class ExpertClaimService { /** V2 price-drop: claim locked by this expert during damage assessment. */ private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void { - requireActorClientKey(actor); - assertClaimCaseForTenant(claim, actor); + if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); + assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`, @@ -4364,12 +4496,12 @@ export class ExpertClaimService { body: UpdateClaimDamagedPartsV2Dto, actor: any, ) { - requireActorClientKey(actor); + if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor); const claim = await this.claimCaseDbService.findById(claimRequestId); if (!claim) { throw new NotFoundException("Claim request not found"); } - assertClaimCaseForTenant(claim, actor); + assertClaimCaseForExpertActor(claim, actor); if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) { throw new BadRequestException( `Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`, diff --git a/src/expert-claim/expert-claim.v2.controller.ts b/src/expert-claim/expert-claim.v2.controller.ts index e7ba6e5..f774c31 100644 --- a/src/expert-claim/expert-claim.v2.controller.ts +++ b/src/expert-claim/expert-claim.v2.controller.ts @@ -58,7 +58,7 @@ class InPersonVisitV2Dto { @Controller("v2/expert-claim") @ApiBearerAuth() @UseGuards(LocalActorAuthGuard, RolesGuard) -@Roles(RoleEnum.DAMAGE_EXPERT) +@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT) export class ExpertClaimV2Controller { constructor( private readonly expertClaimService: ExpertClaimService, diff --git a/src/features/user/repositories/index.ts b/src/features/user/repositories/index.ts new file mode 100644 index 0000000..51189d1 --- /dev/null +++ b/src/features/user/repositories/index.ts @@ -0,0 +1 @@ +export { UserRepository } from "./user.repository"; diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts new file mode 100644 index 0000000..e557dd6 --- /dev/null +++ b/src/features/user/repositories/user.repository.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { User } from "../schemas"; +import { Model } from "mongoose"; + +@Injectable() +export class UserRepository { + constructor( + @InjectModel(User.name) + private readonly model: Model, + ) {} + + async addUser() {} + + async retrieveByCellPhoneNumber( + cellphoneNumber: string, + ): Promise { + return await this.model.findOne({ + cellphoneNumber, + }); + } +} diff --git a/src/features/user/schemas/index.ts b/src/features/user/schemas/index.ts new file mode 100644 index 0000000..d46cf20 --- /dev/null +++ b/src/features/user/schemas/index.ts @@ -0,0 +1,3 @@ +export { OtpSchema, Otp, OtpDocument } from "./otp.schema"; +export { ProfileSchema, Profile, ProfileDocument } from "./profile.schema"; +export { UserSchema, User, UserDocument } from "./user.schema"; diff --git a/src/features/user/schemas/otp.schema.ts b/src/features/user/schemas/otp.schema.ts new file mode 100644 index 0000000..49a009c --- /dev/null +++ b/src/features/user/schemas/otp.schema.ts @@ -0,0 +1,24 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; + +export type OtpDocument = HydratedDocument; + +@Schema({ _id: false }) +export class Otp { + @Prop() + hash: string; + + @Prop() + salt: string; + + @Prop() + expiresAt: Date; + + @Prop({ default: 0 }) + attempts: number; + + @Prop({ default: Date.now }) + generatedAt: Date; +} + +export const OtpSchema = SchemaFactory.createForClass(Otp); diff --git a/src/features/user/schemas/profile.schema.ts b/src/features/user/schemas/profile.schema.ts new file mode 100644 index 0000000..c8f4f61 --- /dev/null +++ b/src/features/user/schemas/profile.schema.ts @@ -0,0 +1,18 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; + +export type ProfileDocument = HydratedDocument; + +@Schema({ _id: false }) +export class Profile { + @Prop() + firstName?: string; + + @Prop() + lastName?: string; + + @Prop() + birthDate?: Date; +} + +export const ProfileSchema = SchemaFactory.createForClass(Profile); diff --git a/src/features/user/schemas/user.schema.ts b/src/features/user/schemas/user.schema.ts new file mode 100644 index 0000000..32de31a --- /dev/null +++ b/src/features/user/schemas/user.schema.ts @@ -0,0 +1,22 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument, Types } from "mongoose"; +import { Otp, OtpSchema, Profile, ProfileSchema } from "./index"; + +export type UserDocument = HydratedDocument; + +@Schema({ + id: true, + timestamps: true, +}) +export class User { + @Prop({ required: true, unique: true, index: true }) + cellphoneNumber: string; + + @Prop({ type: OtpSchema }) + otp?: Otp; + + @Prop({ type: ProfileSchema }) + profile?: Profile; +} + +export const UserSchema = SchemaFactory.createForClass(User); diff --git a/src/features/user/user.controller.ts b/src/features/user/user.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/user/user.module.ts b/src/features/user/user.module.ts new file mode 100644 index 0000000..12bbc94 --- /dev/null +++ b/src/features/user/user.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { UserRepository } from "./repositories"; + +@Module({ + imports: [], + controllers: [], + providers: [UserRepository], + exports: [UserRepository], +}) +export class UserModule {} diff --git a/src/helpers/tenant-scope.ts b/src/helpers/tenant-scope.ts index cf79fc3..bc0e038 100644 --- a/src/helpers/tenant-scope.ts +++ b/src/helpers/tenant-scope.ts @@ -87,6 +87,32 @@ export function assertClaimCaseForTenant( } } +/** + * Returns true when a field expert (no clientKey) initiated this claim via an + * expert-initiated IN_PERSON blame file. + */ +export function claimCaseInitiatedByFieldExpert( + doc: any, + actor: { sub: string }, +): boolean { + return ( + !!doc.initiatedByFieldExpertId && + String(doc.initiatedByFieldExpertId) === String(actor.sub) + ); +} + +/** + * Authorization check that works for both damage experts (clientKey tenant + * scope) and field experts (initiatedByFieldExpertId ownership). + */ +export function assertClaimCaseForExpertActor( + doc: any, + actor: { sub: string; clientKey?: string }, +): void { + if (claimCaseInitiatedByFieldExpert(doc, actor)) return; + assertClaimCaseForTenant(doc, actor); +} + export function resolveGuiltyPartyClientId(doc: any): string | null { const parties: any[] = doc?.parties ?? []; diff --git a/src/request-management/dto/expert-initiated.dto.ts b/src/request-management/dto/expert-initiated.dto.ts index d6a5a92..070ba09 100644 --- a/src/request-management/dto/expert-initiated.dto.ts +++ b/src/request-management/dto/expert-initiated.dto.ts @@ -18,22 +18,4 @@ export class CreateExpertInitiatedFileDto { }) @IsEnum(CreationMethod) creationMethod: CreationMethod; - - // Phone numbers for LINK method - users will access files via normal login - @ApiPropertyOptional({ - description: "First party phone number. Required for LINK method.", - example: "09123456789", - }) - @IsOptional() - @IsString() - firstPartyPhoneNumber?: string; - - @ApiPropertyOptional({ - description: "Second party phone number. Required for LINK method when type is THIRD_PARTY.", - example: "09187654321", - }) - @IsOptional() - @IsString() - secondPartyPhoneNumber?: string; } - diff --git a/src/request-management/dto/party-otp.dto.ts b/src/request-management/dto/party-otp.dto.ts new file mode 100644 index 0000000..6172140 --- /dev/null +++ b/src/request-management/dto/party-otp.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +/** + * One-at-a-time OTP send for the expert-initiated IN_PERSON flow. + * The expert sends an OTP to a single party's phone, collects the code, then + * verifies. Repeat for the second party (no invite link is used). + */ +export class SendPartyOtpDto { + @ApiProperty({ + description: "Phone number of the party to send the OTP to.", + example: "09123456789", + }) + @IsString() + @IsNotEmpty() + phoneNumber: string; +} + +export class VerifyPartyOtpDto { + @ApiProperty({ + description: "Phone number the OTP was sent to.", + example: "09123456789", + }) + @IsString() + @IsNotEmpty() + phoneNumber: string; + + @ApiProperty({ + description: "OTP code collected from the party.", + example: "123456", + }) + @IsString() + @IsNotEmpty() + otp: string; + + @ApiPropertyOptional({ + description: + "Which party this phone belongs to. Optional: when omitted, FIRST is bound if it has no user yet, otherwise SECOND (created if missing).", + enum: ["FIRST", "SECOND"], + example: "FIRST", + }) + @IsOptional() + @IsIn(["FIRST", "SECOND"]) + partyRole?: "FIRST" | "SECOND"; +} diff --git a/src/request-management/dto/send-expert-initiated-link.v2.dto.ts b/src/request-management/dto/send-expert-initiated-link.v2.dto.ts index 508e1e3..7a18549 100644 --- a/src/request-management/dto/send-expert-initiated-link.v2.dto.ts +++ b/src/request-management/dto/send-expert-initiated-link.v2.dto.ts @@ -3,11 +3,10 @@ import { IsNotEmpty, IsString } from "class-validator"; export class SendExpertInitiatedLinkV2Dto { @ApiProperty({ - description: - "Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty", - example: "requestManagement/firstParty", + description: "First party phone number. The link SMS will be sent to this number.", + example: "09123456789", }) @IsString() @IsNotEmpty() - frontendRoute: string; + phoneNumber: string; } diff --git a/src/request-management/expert-initiated-blame.mirror.controller.ts b/src/request-management/expert-initiated-blame.mirror.controller.ts new file mode 100644 index 0000000..bc72519 --- /dev/null +++ b/src/request-management/expert-initiated-blame.mirror.controller.ts @@ -0,0 +1,487 @@ +import { extname } from "node:path"; +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Put, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { + BlameConfessionDtoV2, + CarBodyFormDto, + DescriptionDto, + LocationDto, +} from "./dto/create-request-management.dto"; +import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto"; +import { SendPartyOtpsDto } from "./dto/send-party-otps.dto"; +import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto"; +import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; +import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; +import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto"; +import { RequestManagementService } from "./request-management.service"; +import { PartyRole } from "./entities/schema/partyRole.enum"; + +/** + * Expert-initiated IN_PERSON blame flow that mirrors the normal user blame API + * (`v2/blame-request-management`) one-to-one, so the frontend can reuse the same + * pages by only swapping the route prefix: + * + * `v2/blame-request-management/*` -> `v2/expert-initiated/blame-request-management/*` + * + * The field expert is on the accident scene and fills every step on behalf of + * both parties using the exact same endpoints/bodies as a normal user. By + * contract the FIRST party the expert registers is always the guilty party, and + * the blame is finalized (guilt decided + ready for signatures) without any + * waiting-for-expert phase or invite links. + * + * Sequence: create -> send/verify party OTPs -> first party steps + * (blame-confession, initial-form, upload-video, add-detail-location, + * upload-voice, add-detail-description) -> add-second-party (no link) -> + * second party steps -> sign FIRST + sign SECOND -> COMPLETED. + */ +@ApiTags("expert-initiated blame (mirror v2)") +@Controller("v2/expert-initiated/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.FIELD_EXPERT) +export class ExpertInitiatedBlameMirrorController { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + @Post() + @ApiOperation({ + summary: "[Expert mirror] Create expert-initiated blame file", + description: + "Use creationMethod=IN_PERSON for the on-site flow. Creates a BlameRequest owned by the parties but filled by the expert.", + }) + @ApiBody({ type: CreateExpertInitiatedFileDto }) + async create( + @CurrentUser() expert: any, + @Body() dto: CreateExpertInitiatedFileDto, + ) { + return this.requestManagementService.createExpertInitiatedBlameV2( + expert, + dto, + ); + } + + @Get() + @ApiOperation({ + summary: "[Expert mirror] List my expert-initiated blame files", + }) + async list(@CurrentUser() expert: any) { + return this.requestManagementService.getMyExpertInitiatedFilesV2(expert); + } + + @Post("send-link/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: SendExpertInitiatedLinkV2Dto }) + @ApiOperation({ + summary: "[Expert mirror] Send blame link to first party (LINK method)", + description: + "For files created with creationMethod=LINK. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.", + }) + async sendLink( + @CurrentUser() expert: any, + @Param("requestId") requestId: string, + @Body() dto: SendExpertInitiatedLinkV2Dto, + ) { + return this.requestManagementService.sendLinkV2(expert, requestId, dto); + } + + @Post("send-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: SendPartyOtpDto }) + @ApiOperation({ + summary: "[Expert mirror] Send OTP to one party (one-at-a-time IN_PERSON)", + description: + "Sends an OTP to a single phone number. Use this for the sequential flow: send to first party -> verify -> fill data -> send to second party -> verify -> continue. No invite link is sent.", + }) + async sendPartyOtp( + @CurrentUser() expert: any, + @Param("requestId") requestId: string, + @Body() dto: SendPartyOtpDto, + ) { + return this.requestManagementService.sendPartyOtpV2(expert, requestId, dto); + } + + @Post("verify-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: VerifyPartyOtpDto }) + @ApiOperation({ + summary: "[Expert mirror] Verify one party's OTP (one-at-a-time IN_PERSON)", + description: + "Verifies a single party's OTP and binds their account. `partyRole` is optional (inferred as FIRST until the first party is bound, then SECOND).", + }) + async verifyPartyOtp( + @CurrentUser() expert: any, + @Param("requestId") requestId: string, + @Body() dto: VerifyPartyOtpDto, + ) { + return this.requestManagementService.verifyPartyOtpV2( + expert, + requestId, + dto, + ); + } + + // @Post("send-party-otps/:requestId") + // @ApiParam({ name: "requestId" }) + // @ApiBody({ type: SendPartyOtpsDto }) + // @ApiOperation({ + // summary: "[Expert mirror] Send OTP to party/parties (IN_PERSON)", + // description: + // "Bind the parties' user accounts before filling their data. Required so guilt and the claim owner can be resolved.", + // }) + // async sendPartyOtps( + // @CurrentUser() expert: any, + // @Param("requestId") requestId: string, + // @Body() dto: SendPartyOtpsDto, + // ) { + // return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto); + // } + + // @Post("verify-party-otps/:requestId") + // @ApiParam({ name: "requestId" }) + // @ApiBody({ type: VerifyPartyOtpsDto }) + // @ApiOperation({ summary: "[Expert mirror] Verify party OTPs (IN_PERSON)" }) + // async verifyPartyOtps( + // @CurrentUser() expert: any, + // @Param("requestId") requestId: string, + // @Body() dto: VerifyPartyOtpsDto, + // ) { + // return this.requestManagementService.verifyPartyOtpsV2( + // expert, + // requestId, + // dto, + // ); + // } + + // @Post("/blame-confession/:requestId") + // @ApiParam({ name: "requestId" }) + // @ApiBody({ type: BlameConfessionDtoV2 }) + // @ApiOperation({ + // summary: "[Expert mirror] First-party blame confession", + // description: + // "THIRD_PARTY step. The FIRST party is the guilty party, so send imGuilty=true.", + // }) + // async blameConfession( + // @Param("requestId") requestId: string, + // @Body() body: BlameConfessionDtoV2, + // @CurrentUser() expert: any, + // @Body("partyRole") partyRole?: string, + // ) { + // return this.requestManagementService.blameConfessionV2( + // requestId, + // body, + // expert, + // partyRole, + // ); + // } + + @Post("/car-body-form/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: CarBodyFormDto }) + @ApiOperation({ summary: "[Expert mirror] CAR_BODY accident type" }) + async carBodyForm( + @Param("requestId") requestId: string, + @Body() body: CarBodyFormDto, + @CurrentUser() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.carBodyAccidentTypeFormV2( + requestId, + body, + expert, + partyRole, + ); + } + + @Post("/initial-form/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: AddPlateDto }) + @ApiOperation({ + summary: "[Expert mirror] Initial form (plate/insurance) for current party", + description: + "Fills the FIRST or SECOND party plate/insurance/vehicle data depending on the current workflow step.", + }) + async initialForm( + @Param("requestId") requestId: string, + @Body() body: AddPlateDto, + @CurrentUser() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.initialFormV2( + requestId, + body, + expert, + partyRole, + ); + } + + @ApiBody({ + schema: { + type: "object", + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/video", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `expert-${file.originalname}-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ name: "requestId" }) + @Post("upload-video/:requestId") + @ApiOperation({ summary: "[Expert mirror] Upload first-party video" }) + async uploadVideo( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + @UploadedFile() file?: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + return this.requestManagementService.uploadFirstPartyVideoV2( + requestId, + file, + expert, + ); + } + + @Post("/add-detail-location/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: LocationDto }) + @ApiOperation({ summary: "[Expert mirror] Add location for current party" }) + async addLocation( + @Param("requestId") requestId: string, + @Body() body: LocationDto, + @CurrentUser() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addDetailLocationV2( + requestId, + body, + expert, + partyRole, + ); + } + + @ApiBody({ + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + partyRole: { + type: "string", + enum: ["FIRST", "SECOND"], + description: + "Optional explicit party selector; must match the party the file is currently collecting (FIRST for these steps).", + }, + }, + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/voice", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + const flname = file.originalname.split(".")[0]; + callback(null, `expert-${flname}-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ name: "requestId" }) + @Post("upload-voice/:requestId") + @ApiOperation({ summary: "[Expert mirror] Upload voice for current party" }) + async uploadVoice( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + @UploadedFile() voice?: Express.Multer.File, + @Body("partyRole") partyRole?: string, + ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); + return this.requestManagementService.uploadVoiceV2( + requestId, + voice, + expert, + partyRole, + ); + } + + @Post("/add-detail-description/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: DescriptionDto }) + @ApiOperation({ + summary: "[Expert mirror] Add description for current party", + description: + "For THIRD_PARTY, submitting the SECOND party description finalizes guilt (FIRST party = guilty) and moves the file straight to WAITING_FOR_SIGNATURES.", + }) + async addDescription( + @Param("requestId") requestId: string, + @Body() body: DescriptionDto, + @CurrentUser() expert: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addDescriptionV2( + requestId, + body, + expert, + partyRole, + ); + } + + @Post("add-second-party/:phoneNumber/:requestId/") + @ApiParam({ name: "phoneNumber" }) + @ApiParam({ name: "requestId" }) + @ApiOperation({ + summary: "[Expert mirror] Advance to second party (no SMS link)", + description: + "IN_PERSON variant of add-second-party: there is no invite link. Advances the workflow so the expert can fill the second party's steps. `frontendRoute` is accepted for route compatibility but ignored.", + }) + async addSecondParty( + @Param("phoneNumber") phoneNumber: string, + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + ) { + return this.requestManagementService.expertAdvanceToSecondPartyV2( + requestId, + expert, + phoneNumber, + ); + } + + @Put("sign/:requestId") + @ApiParam({ name: "requestId" }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "[Expert mirror] Upload a party's on-site signature", + description: + "Collect each party's signature on site. CAR_BODY: partyRole=FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed (accepted), the blame case completes.", + }) + @ApiBody({ + schema: { + type: "object", + required: ["partyRole", "sign"], + properties: { + partyRole: { type: "string", enum: ["FIRST", "SECOND"] }, + isAccept: { type: "boolean", default: true }, + sign: { type: "string", format: "binary" }, + }, + }, + }) + @UseInterceptors( + FileInterceptor("sign", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/signs", + filename: (req, file, callback) => { + const unique = Date.now(); + const ext = extname(file.originalname); + callback(null, `expert-party-${unique}${ext}`); + }, + }), + }), + ) + async sign( + @Param("requestId") requestId: string, + @Body() body: { partyRole?: string; isAccept?: string | boolean }, + @CurrentUser() expert: any, + @UploadedFile() sign: Express.Multer.File, + ) { + // Guard: accident fields (accidentWay, etc.) must be filled before signing. + // This prevents the expert from bypassing add-accident-fields and avoids the + // double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES + // after a signature was already recorded. + const requestData = await this.requestManagementService.getBlameRequestV2( + requestId, + expert, + ); + if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.", + ); + } + + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + const partyRole = + body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; + const isAccept = !(body?.isAccept === false || body?.isAccept === "false"); + return this.requestManagementService.expertUploadPartySignatureV2( + expert, + requestId, + partyRole, + isAccept, + sign, + ); + } + + @Post("add-accident-fields/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: ExpertAccidentFieldsDto }) + @ApiOperation({ + summary: "[Expert mirror] Add accident fields and advance to signatures", + description: + "Saves accidentWay, accidentReason, and accidentType for the blame file. " + + "In an IN_PERSON expert-initiated flow the first party is always guilty, so " + + "calling this endpoint both records the accident details and advances the workflow " + + "from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES.", + }) + async addAccidentFields( + @Param("requestId") requestId: string, + @Body() fields: ExpertAccidentFieldsDto, + @CurrentUser() expert: any, + ) { + return this.requestManagementService.expertAddAccidentFieldsForBlameV2( + expert, + requestId, + fields, + ); + } + + @Get(":requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ summary: "[Expert mirror] Get one blame request" }) + async getOne( + @Param("requestId") requestId: string, + @CurrentUser() expert: any, + ) { + return this.requestManagementService.getBlameRequestV2(requestId, expert); + } +} diff --git a/src/request-management/expert-initiated.v2.controller.ts b/src/request-management/expert-initiated.v2.controller.ts index 238ea2e..cf3236f 100644 --- a/src/request-management/expert-initiated.v2.controller.ts +++ b/src/request-management/expert-initiated.v2.controller.ts @@ -36,8 +36,8 @@ import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto"; import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto"; import { SendPartyOtpsDto } from "./dto/send-party-otps.dto"; -import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto"; import { SendExpertInitiatedLinkV2Dto } from "./dto/send-expert-initiated-link.v2.dto"; +import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { PartyRole } from "./entities/schema/partyRole.enum"; @@ -158,34 +158,12 @@ export class ExpertInitiatedV2Controller { @Post("send-link/:requestId") @ApiOperation({ - summary: "[V2] Send blame link to party/parties (LINK)", + summary: "[V2] Send blame link to first party (LINK)", description: - "For expert-initiated LINK files only. Sends SMS with template `yara-field-expert-link` to first party (and second party for THIRD_PARTY). Tokens: token=#1(بدنه/ثالث), token2=#2(expert name), token3=#3(invite link built exactly like add-second-party flow: `${URL}/{frontendRoute}?token={requestId}`).", + "For expert-initiated LINK files only. Provide the first party phone number; the service stores it, registers the user if needed, and sends the invite link via SMS.", }) @ApiParam({ name: "requestId", description: "Blame request ID" }) @ApiBody({ type: SendExpertInitiatedLinkV2Dto }) - @ApiResponse({ - status: 200, - description: "Link sent; recipients can open and fill via normal user flow", - schema: { - type: "object", - properties: { - sent: { type: "boolean" }, - linkUrl: { type: "string" }, - sentTo: { - type: "array", - items: { - type: "object", - properties: { - role: { type: "string", enum: ["FIRST", "SECOND"] }, - phoneNumber: { type: "string" }, - smsSent: { type: "boolean" }, - }, - }, - }, - }, - }, - }) async sendLinkV2( @CurrentUser() expert: any, @Param("requestId") requestId: string, diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 7129abe..70af222 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -39,6 +39,7 @@ import { RequestManagementController } from "./request-management.controller"; import { RequestManagementV2Controller } from "./request-management.v2.controller"; import { ExpertInitiatedController } from "./expert-initiated.controller"; import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller"; +import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller"; import { RegistrarInitiatedController } from "./registrar-initiated.controller"; @Module({ @@ -72,6 +73,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller"; RequestManagementV2Controller, ExpertInitiatedController, ExpertInitiatedV2Controller, + ExpertInitiatedBlameMirrorController, RegistrarInitiatedController, ], providers: [ diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 09b4fb1..118e5b6 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -181,7 +181,34 @@ export class RequestManagementService { ); } - private assertPartyOwner(party: any, user: any, errMsg: string) { + /** + * True when `user` is the field-expert/registrar who created this IN_PERSON + * file and is therefore allowed to act on behalf of its parties (fill the + * normal granular steps for both parties). Normal USER callers never match. + */ + private isBlameOnBehalfActor(req: any, user: any): boolean { + if (!req || !user) return false; + if (req.creationMethod !== CreationMethod.IN_PERSON) return false; + if (user.role === RoleEnum.FIELD_EXPERT) { + return ( + !!req.expertInitiated && + !!req.initiatedByFieldExpertId && + String(req.initiatedByFieldExpertId) === String(user.sub) + ); + } + if (user.role === RoleEnum.REGISTRAR) { + return ( + !!req.registrarInitiated && + !!req.initiatedByRegistrarId && + String(req.initiatedByRegistrarId) === String(user.sub) + ); + } + return false; + } + + private assertPartyOwner(party: any, user: any, errMsg: string, req?: any) { + // The initiating field-expert/registrar fills steps on behalf of parties. + if (req && this.isBlameOnBehalfActor(req, user)) return; const partyUserId = party?.person?.userId ? String(party.person.userId) : null; @@ -192,6 +219,30 @@ export class RequestManagementService { if (!ok) throw new ForbiddenException(errMsg); } + /** + * For the on-behalf (expert/registrar) flow only: when the caller explicitly + * labels which party a submission belongs to (`partyRole`), make sure it + * matches the party the file is currently collecting data for. Keeps the + * sequential workflow intact while guaranteeing the expert never attributes a + * submission to the wrong party. No-op for normal users (who never pass it). + */ + private assertExpectedPartyRole( + expectedRole: string | undefined, + actualRole: PartyRole, + onBehalf: boolean, + ) { + if (!onBehalf || expectedRole == null || expectedRole === "") return; + const want = + String(expectedRole).toUpperCase() === "SECOND" + ? PartyRole.SECOND + : PartyRole.FIRST; + if (want !== actualRole) { + throw new BadRequestException( + `partyRole mismatch: this file is currently collecting the ${actualRole} party's data, but partyRole=${want} was sent. Submit ${actualRole} party data (advance the file to the other party first if needed).`, + ); + } + } + private async advanceWorkflowToNext( req: any, submittedStepKey: WorkflowStep, @@ -610,6 +661,7 @@ export class RequestManagementService { requestId: string, body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean }, user: any, + expectedRole?: string, ) { const step2 = await this.getWorkflowStep({ stepNumber: 2 }); const step2Key = step2.stepKey as WorkflowStep; @@ -653,13 +705,23 @@ export class RequestManagementService { (firstParty?.person?.phoneNumber && firstParty.person.phoneNumber === user?.username); - if (!isOwner) { + if (!isOwner && !this.isBlameOnBehalfActor(req, user)) { throw new ForbiddenException("Only first party can submit this step"); } + this.assertExpectedPartyRole( + expectedRole, + PartyRole.FIRST, + this.isBlameOnBehalfActor(req, user), + ); + // Set userId for first party if not already set if (!firstParty.person) firstParty.person = {} as any; - if (!firstParty.person.userId && user?.sub) { + if ( + !firstParty.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { firstParty.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -755,6 +817,7 @@ export class RequestManagementService { requestId: string, body: { car?: boolean; object?: boolean }, user: any, + expectedRole?: string, ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); @@ -763,12 +826,17 @@ export class RequestManagementService { "This endpoint is only for CAR_BODY type requests", ); } - if (!req.workflow?.nextStep) { + if (!req.workflow?.currentStep) { throw new BadRequestException("Request workflow is not initialized"); } - if (req.workflow.nextStep !== WorkflowStep.CAR_BODY_ACCIDENT_TYPE) { + // Normal user flow: currentStep=CREATED, nextStep=CAR_BODY_ACCIDENT_TYPE + // Expert IN_PERSON: currentStep=CAR_BODY_ACCIDENT_TYPE, nextStep=FIRST_VIDEO + const isCorrectStep = + req.workflow.currentStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE || + req.workflow.nextStep === WorkflowStep.CAR_BODY_ACCIDENT_TYPE; + if (!isCorrectStep) { throw new BadRequestException( - `Invalid step. Expected nextStep=CAR_BODY_ACCIDENT_TYPE but got ${req.workflow.nextStep}`, + `Invalid step. Expected currentStep or nextStep to be CAR_BODY_ACCIDENT_TYPE but got currentStep=${req.workflow.currentStep}, nextStep=${req.workflow.nextStep}`, ); } @@ -780,10 +848,21 @@ export class RequestManagementService { firstParty, user, "Only first party can submit this step", + req, + ); + + this.assertExpectedPartyRole( + expectedRole, + PartyRole.FIRST, + this.isBlameOnBehalfActor(req, user), ); if (!firstParty.person) firstParty.person = {} as any; - if (!firstParty.person.userId && user?.sub) { + if ( + !firstParty.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { firstParty.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -869,7 +948,7 @@ export class RequestManagementService { (firstParty?.person?.phoneNumber && firstParty.person.phoneNumber === user?.username); - if (!isOwner) { + if (!isOwner && !this.isBlameOnBehalfActor(req, user)) { throw new ForbiddenException("Only first party can upload this video"); } @@ -944,7 +1023,12 @@ export class RequestManagementService { }; } - async initialFormV2(requestId: string, body: AddPlateDto, user: any) { + async initialFormV2( + requestId: string, + body: AddPlateDto, + user: any, + expectedRole?: string, + ) { try { const req = await this.blameRequestDbService.findById(requestId); if (!req) { @@ -977,11 +1061,22 @@ export class RequestManagementService { party, user, `Only ${role} party can submit this step`, + req, + ); + + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), ); // 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) { + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -1333,7 +1428,12 @@ export class RequestManagementService { } } - async addDetailLocationV2(requestId: string, body: LocationDto, user: any) { + async addDetailLocationV2( + requestId: string, + body: LocationDto, + user: any, + expectedRole?: string, + ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { @@ -1359,11 +1459,22 @@ export class RequestManagementService { party, user, "Only the related party can submit location", + req, + ); + + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), ); // 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) { + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -1399,6 +1510,7 @@ export class RequestManagementService { requestId: string, voice: Express.Multer.File, user: any, + expectedRole?: string, ) { if (!voice) throw new BadRequestException("File is required"); @@ -1427,11 +1539,22 @@ export class RequestManagementService { party, user, "Only the related party can upload voice", + req, + ); + + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), ); // 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) { + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -1477,7 +1600,12 @@ export class RequestManagementService { }; } - async addDescriptionV2(requestId: string, body: DescriptionDto, user: any) { + async addDescriptionV2( + requestId: string, + body: DescriptionDto, + user: any, + expectedRole?: string, + ) { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.workflow?.currentStep) { @@ -1503,11 +1631,22 @@ export class RequestManagementService { party, user, "Only the related party can submit description", + req, + ); + + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), ); // 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) { + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { party.person.userId = Types.ObjectId.isValid(user.sub) ? new Types.ObjectId(user.sub) : undefined; @@ -1587,7 +1726,8 @@ export class RequestManagementService { isSecondThirdParty && closedByMutualAgreement && req.status === CaseStatus.WAITING_FOR_SIGNATURES && - req.blameStatus === BlameStatus.AGREED + req.blameStatus === BlameStatus.AGREED && + !this.isBlameOnBehalfActor(req, user) ) { const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); @@ -1628,7 +1768,45 @@ export class RequestManagementService { } } - if (!closedByMutualAgreement) { + const expertOnBehalfThirdPartySecond = + !closedByMutualAgreement && + isSecondThirdParty && + this.isBlameOnBehalfActor(req, user); + + if (expertOnBehalfThirdPartySecond) { + // Expert-initiated IN_PERSON: the field expert is on the accident scene + // and decides guilt directly. By contract the FIRST party (the one the + // expert registers first) is always the guilty party, so we record the + // decision here and move straight to signatures — there is no + // waiting-for-field-expert phase for these files. + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; + if (!guiltyUserId) { + throw new BadRequestException( + "First party must be verified (OTP) before finishing the blame file.", + ); + } + const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; + const damagedPhone = (req.parties?.[secondIdx]?.person as any) + ?.phoneNumber; + if (!(req as any).expert) (req as any).expert = {}; + (req as any).expert.decision = { + guiltyPartyId: new Types.ObjectId(String(guiltyUserId)), + description: `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, + } as any; + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + if (!completed.includes(stepKey)) completed.push(stepKey); + if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) { + completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION); + } + req.workflow.completedSteps = completed; + req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; + req.workflow.nextStep = undefined; + req.status = CaseStatus.WAITING_FOR_SIGNATURES; + } else if (!closedByMutualAgreement) { if (stepKey === WorkflowStep.SECOND_DESCRIPTION) { req.status = CaseStatus.WAITING_FOR_EXPERT; } @@ -1711,6 +1889,7 @@ export class RequestManagementService { firstParty, user, "Only first party can invite second party", + req, ); // Validate second party phone number is different @@ -1823,6 +2002,113 @@ export class RequestManagementService { } } + /** + * Expert-initiated IN_PERSON variant of `addSecondPartyV2`. + * + * The expert is physically with both parties, so there is no invite link/SMS: + * we simply ensure the SECOND party exists and advance the workflow from + * FIRST_INVITE_SECOND to SECOND_INITIAL_FORM so the expert can fill the + * second party's steps using the same granular endpoints. + */ + async expertAdvanceToSecondPartyV2( + requestId: string, + user: any, + phoneNumber?: string, + ) { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if (!this.isBlameOnBehalfActor(req, user)) { + throw new ForbiddenException( + "Only the initiating expert/registrar can advance this in-person file.", + ); + } + if (req.type !== BlameRequestType.THIRD_PARTY) { + throw new BadRequestException( + "Only THIRD_PARTY files have a second party.", + ); + } + 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}`, + ); + } + + if (!phoneNumber) { + throw new BadRequestException( + "phoneNumber is required to register the second party", + ); + } + + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + if ( + firstIdx !== -1 && + (req.parties[firstIdx]?.person as any)?.phoneNumber === phoneNumber + ) { + throw new BadRequestException( + "Second party phone number cannot be the same as first party", + ); + } + + // Create second party placeholder (no userId yet — will be bound after OTP verify) + let secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondIdx === -1) { + if (!Array.isArray(req.parties)) req.parties = []; + req.parties.push({ + role: PartyRole.SECOND, + person: { phoneNumber } as any, + } as any); + } else { + if (!req.parties[secondIdx].person) + req.parties[secondIdx].person = {} as any; + (req.parties[secondIdx].person as any).phoneNumber = phoneNumber; + } + + // Send OTP to second party — expert collects it from them in person + try { + await this.userAuthService.sendOtpRequest(phoneNumber); + } catch (e: any) { + if (e?.message?.includes("Wait for expiry")) { + throw new BadRequestException( + `(${phoneNumber}): OTP was recently sent. Wait for the expiry time before requesting again.`, + ); + } + throw e; + } + + // Stay at FIRST_INVITE_SECOND — workflow will advance only after verifyPartyOtp + req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "SECOND_PARTY_OTP_SENT", + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + inPersonExpert: true, + phoneNumber, + note: "OTP sent; call verify-party-otp to bind and advance to SECOND_INITIAL_FORM", + }, + } as any); + + await (req as any).save(); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + message: `OTP sent to ${phoneNumber}. Collect the code and call verify-party-otp.`, + }; + } + // async createRequest(user, type) { // const reqNumber = new ShortUniqueId({ counter: 1 }); // const publicId = await this.publicIdService.generateRequestPublicId(); @@ -4036,30 +4322,6 @@ export class RequestManagementService { ? BlameRequestType.CAR_BODY : BlameRequestType.THIRD_PARTY; - if (dto.creationMethod === CreationMethod.LINK) { - if (!dto.firstPartyPhoneNumber) { - throw new BadRequestException( - "First party phone number is required for LINK method", - ); - } - if ( - type === BlameRequestType.THIRD_PARTY && - !dto.secondPartyPhoneNumber - ) { - throw new BadRequestException( - "Second party phone number is required for LINK method with THIRD_PARTY type", - ); - } - if ( - type === BlameRequestType.THIRD_PARTY && - dto.firstPartyPhoneNumber === dto.secondPartyPhoneNumber - ) { - throw new BadRequestException( - "First and second party phone numbers must be different", - ); - } - } - const firstStep = await this.getWorkflowStep({ stepNumber: 1 }); if (!firstStep?.stepKey) { throw new InternalServerErrorException( @@ -4079,37 +4341,10 @@ export class RequestManagementService { const publicId = await this.publicIdService.generateRequestPublicId(); - const parties: any[] = []; - - if (dto.creationMethod === CreationMethod.LINK) { - const firstUserId = await this.getOrCreateUserByPhoneNumber( - dto.firstPartyPhoneNumber, - ); - parties.push({ - role: PartyRole.FIRST, - person: { - userId: firstUserId, - phoneNumber: dto.firstPartyPhoneNumber, - }, - }); - if (type === BlameRequestType.THIRD_PARTY && dto.secondPartyPhoneNumber) { - const secondUserId = await this.getOrCreateUserByPhoneNumber( - dto.secondPartyPhoneNumber, - ); - parties.push({ - role: PartyRole.SECOND, - person: { - userId: secondUserId, - phoneNumber: dto.secondPartyPhoneNumber, - }, - }); - } - } else { - parties.push({ - role: PartyRole.FIRST, - person: {}, - }); - } + // Always start with an empty first-party placeholder. + // For LINK files the phone + userId are stored when send-link is called. + // For IN_PERSON files the phone + userId are stored when verify-party-otp is called. + const parties: any[] = [{ role: PartyRole.FIRST, person: {} }]; const created = await this.blameRequestDbService.create({ publicId, @@ -4170,63 +4405,49 @@ export class RequestManagementService { async sendLinkV2( expert: any, requestId: string, - dto: { frontendRoute: string }, + dto: { phoneNumber: string }, ): Promise<{ sent: boolean; - linkUrl: string; - sentTo: { role: string; phoneNumber: string; smsSent: boolean }[]; + sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[]; }> { const req = await this.blameRequestDbService.findById(requestId); if (!req) throw new NotFoundException("Request not found"); if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) { throw new BadRequestException( - "This endpoint is only for expert-initiated LINK blame files. Create the file with creationMethod LINK first.", + "This endpoint is only for expert-initiated LINK blame files.", ); } this.verifyExpertAccessForBlameV2(req, expert); - if (!dto?.frontendRoute) { - throw new BadRequestException("frontendRoute is required"); - } + const phone = (dto?.phoneNumber || "").trim(); + if (!phone) throw new BadRequestException("phoneNumber is required"); + if (!process.env.URL) { throw new InternalServerErrorException( "URL environment variable is not configured", ); } - const linkUrl = this.smsOrchestrationService.buildInviteLink( - dto.frontendRoute, - requestId, - ); - const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; - - const sentTo: { role: string; phoneNumber: string; smsSent: boolean }[] = - []; - const sendSms = async ( - phone: string, - role: PartyRole, - ): Promise => { - return this.smsOrchestrationService.sendFieldExpertLink({ - receptor: phone, - type: req.type, - expertLastName: expertName, - link: linkUrl, - }); - }; + // Store / update the first party's phone and bind a user account const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); - if (firstIdx !== -1 && req.parties[firstIdx]?.person?.phoneNumber) { - const phone = req.parties[firstIdx].person.phoneNumber; - const smsSent = await sendSms(phone, PartyRole.FIRST); - sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent }); - } - if (req.type === BlameRequestType.THIRD_PARTY) { - const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); - if (secondIdx !== -1 && req.parties[secondIdx]?.person?.phoneNumber) { - const phone = req.parties[secondIdx].person.phoneNumber; - const smsSent = await sendSms(phone, PartyRole.SECOND); - sentTo.push({ role: PartyRole.SECOND, phoneNumber: phone, smsSent }); - } - } + if (firstIdx === -1) + throw new BadRequestException("First party not found on request"); + const userId = await this.getOrCreateUserByPhoneNumber(phone); + if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any; + req.parties[firstIdx].person.phoneNumber = phone; + req.parties[firstIdx].person.userId = userId; + + const expertName = `${expert?.lastName || ""}`.trim() || "کارشناس"; + const sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[] = []; + + const firstLink = this.smsOrchestrationService.buildBlamePartyLink(requestId, "FIRST"); + const smsSent = await this.smsOrchestrationService.sendFieldExpertLink({ + receptor: phone, + type: req.type, + expertLastName: expertName, + link: firstLink, + }); + sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink }); if (!Array.isArray((req as any).history)) (req as any).history = []; (req as any).history.push({ @@ -4236,18 +4457,12 @@ export class RequestManagementService { actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorType: "field_expert", }, - metadata: { - linkUrl, - sentTo, - template: "yara-field-expert-link", - frontendRoute: dto.frontendRoute, - }, + metadata: { sentTo, template: "yara-field-expert-link" }, }); await (req as any).save(); return { sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent), - linkUrl, sentTo, }; } @@ -4440,6 +4655,279 @@ export class RequestManagementService { }; } + /** + * Send an OTP to a single party phone (one-at-a-time IN_PERSON flow). + * The expert sends to the first party, collects the code and verifies, fills + * their data, then enters the second party's phone and repeats — instead of + * sending an invite link, the OTP registers/authenticates the second party. + */ + async sendPartyOtpV2( + actor: any, + requestId: string, + dto: { phoneNumber: string }, + ): Promise<{ sent: boolean; message: string }> { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if ( + (!req.expertInitiated && !req.registrarInitiated) || + req.creationMethod !== CreationMethod.IN_PERSON + ) { + throw new BadRequestException( + "This endpoint is only for expert-initiated IN_PERSON blame files.", + ); + } + this.verifyExpertAccessForBlameV2(req, actor); + const phone = (dto?.phoneNumber || "").trim(); + if (!phone) throw new BadRequestException("phoneNumber is required"); + try { + await this.userAuthService.sendOtpRequest(phone); + } catch (e: any) { + if (e?.message?.includes("Wait for expiry")) { + throw new BadRequestException( + `(${phone}): OTP was recently sent. Wait for the expiry time before requesting again.`, + ); + } + throw e; + } + return { + sent: true, + message: `OTP sent to ${phone}. Collect the code from the party, then call verify-party-otp.`, + }; + } + + /** + * Verify a single party OTP and bind that party's user account. + * `partyRole` is optional: when omitted, FIRST is bound if it has no user yet, + * otherwise SECOND (created if missing). For THIRD_PARTY the second party is + * registered here instead of receiving an invite link. + */ + async verifyPartyOtpV2( + actor: any, + requestId: string, + dto: { phoneNumber: string; otp: string; partyRole?: string }, + ): Promise<{ verified: boolean; partyRole: PartyRole; message: string }> { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + if ( + (!req.expertInitiated && !req.registrarInitiated) || + req.creationMethod !== CreationMethod.IN_PERSON + ) { + throw new BadRequestException( + "This endpoint is only for expert-initiated IN_PERSON blame files.", + ); + } + this.verifyExpertAccessForBlameV2(req, actor); + + const phone = (dto?.phoneNumber || "").trim(); + const otp = (dto?.otp || "").trim(); + if (!phone || !otp) { + throw new BadRequestException("phoneNumber and otp are required"); + } + + if (!Array.isArray(req.parties)) req.parties = []; + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + + // Resolve which party this phone belongs to. + let role: PartyRole; + if (dto.partyRole === "SECOND" || dto.partyRole === "FIRST") { + role = dto.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; + } else { + const firstHasUser = !!( + firstIdx !== -1 && req.parties[firstIdx]?.person?.userId + ); + role = firstHasUser ? PartyRole.SECOND : PartyRole.FIRST; + } + + if ( + role === PartyRole.SECOND && + req.type !== BlameRequestType.THIRD_PARTY + ) { + throw new BadRequestException("CAR_BODY has only one (first) party."); + } + + // Verify the OTP and resolve the user id. + const now = Date.now(); + const user = await this.userDbService.findOne({ + $or: [{ username: phone }, { mobile: phone }], + }); + if (!user) + throw new BadRequestException(`User not found for phone: ${phone}`); + const u = user as any; + if (u.otp == null) + throw new BadRequestException( + `No OTP requested for ${phone}. Send an OTP first.`, + ); + if (u.otpExpire < now) + throw new BadRequestException( + `OTP expired for ${phone}. Request a new OTP.`, + ); + const valid = await this.hashService.compare(otp, u.otp); + if (!valid) throw new BadRequestException(`Invalid OTP for ${phone}`); + const userId = u._id as Types.ObjectId; + + if (role === PartyRole.FIRST) { + if (firstIdx === -1) + throw new BadRequestException("First party not found on request"); + if (!req.parties[firstIdx].person) + req.parties[firstIdx].person = {} as any; + req.parties[firstIdx].person.userId = userId; + req.parties[firstIdx].person.phoneNumber = phone; + } else { + const firstPhone = + firstIdx !== -1 + ? (req.parties[firstIdx]?.person as any)?.phoneNumber + : undefined; + if (firstPhone && firstPhone === phone) { + throw new BadRequestException( + "Second party phone number cannot be the same as first party.", + ); + } + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + if (secondIdx === -1) { + req.parties.push({ + role: PartyRole.SECOND, + person: { userId, phoneNumber: phone }, + } as any); + } else { + if (!req.parties[secondIdx].person) + req.parties[secondIdx].person = {} as any; + req.parties[secondIdx].person.userId = userId; + req.parties[secondIdx].person.phoneNumber = phone; + } + } + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "PARTY_OTP_VERIFIED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: + actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + }, + metadata: { partyRole: role, phoneNumber: phone }, + } as any); + + // For IN_PERSON expert/registrar flow: after first party OTP is verified and + // the workflow is still at CREATED, advance past any intro step to the first + // real data-entry step. + // + // THIRD_PARTY: skip FIRST_BLAME_CONFESSION (first party is always guilty) and + // land on FIRST_VIDEO. + // CAR_BODY: no confession exists; advance from CREATED to CAR_BODY_ACCIDENT_TYPE + // so the expert can fill the car-body form before video. + if ( + role === PartyRole.FIRST && + req.workflow?.currentStep === WorkflowStep.CREATED + ) { + const completed = Array.isArray(req.workflow.completedSteps) + ? req.workflow.completedSteps + : []; + + if (req.type === BlameRequestType.CAR_BODY) { + // Advance CREATED → CAR_BODY_ACCIDENT_TYPE + // nextStep was already set to CAR_BODY_ACCIDENT_TYPE at creation time; + // look up its own nextPossibleSteps so we can set nextStep correctly. + const carBodyStepDoc = await this.getWorkflowStep({ + stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE, + }); + const afterCarBody = + (carBodyStepDoc?.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_VIDEO; + + req.workflow.completedSteps = completed; + req.workflow.currentStep = WorkflowStep.CAR_BODY_ACCIDENT_TYPE; + req.workflow.nextStep = afterCarBody; + + req.history.push({ + type: "AUTO_ADVANCED_TO_CAR_BODY_FORM", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: + `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: + actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + }, + metadata: { advancedTo: WorkflowStep.CAR_BODY_ACCIDENT_TYPE }, + } as any); + } else { + // THIRD_PARTY: skip confession — first party is always guilty in IN_PERSON + const step2 = await this.getWorkflowStep({ stepNumber: 2 }); // FIRST_BLAME_CONFESSION + const step2Key = step2.stepKey as WorkflowStep; + const step3 = await this.getWorkflowStep({ stepNumber: 3 }); // FIRST_VIDEO + const step3Key = step3.stepKey as WorkflowStep; + const nextAfterVideo = + (step3.nextPossibleSteps?.[0] as WorkflowStep) ?? + WorkflowStep.FIRST_INITIAL_FORM; + + if (!completed.includes(step2Key)) completed.push(step2Key); + req.workflow.completedSteps = completed; + req.workflow.currentStep = step3Key; + req.workflow.nextStep = nextAfterVideo; + + // Auto-guilt: first party is always guilty in expert-initiated IN_PERSON + const fIdx = this.getPartyIndex(req, PartyRole.FIRST); + if (fIdx !== -1) { + if (!req.parties[fIdx].statement) + req.parties[fIdx].statement = {} as any; + req.parties[fIdx].statement.admitsGuilt = true; + req.parties[fIdx].statement.claimsDamage = false; + req.parties[fIdx].statement.acceptsExpertOpinion = false; + } + req.blameStatus = BlameStatus.AGREED; + + req.history.push({ + type: "AUTO_CONFESSION_SKIPPED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: + `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: + actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + }, + metadata: { + reason: + "IN_PERSON expert-initiated: first party is always guilty; confession auto-resolved", + stepKey: step2Key, + advancedTo: step3Key, + }, + } as any); + } + } + + // For IN_PERSON expert/registrar flow: after second party OTP is verified and + // the workflow is at FIRST_INVITE_SECOND, advance to SECOND_INITIAL_FORM. + if ( + role === PartyRole.SECOND && + req.workflow?.currentStep === WorkflowStep.FIRST_INVITE_SECOND + ) { + await this.advanceWorkflowToNext(req, WorkflowStep.FIRST_INVITE_SECOND); + req.status = CaseStatus.WAITING_FOR_SECOND_PARTY; + + req.history.push({ + type: "SECOND_PARTY_OTP_VERIFIED_ADVANCED", + actor: { + actorId: new Types.ObjectId(actor.sub), + actorName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(), + actorType: + actor?.role === RoleEnum.REGISTRAR ? "registrar" : "field_expert", + }, + metadata: { + phoneNumber: phone, + advancedTo: WorkflowStep.SECOND_INITIAL_FORM, + }, + } as any); + } + + await (req as any).save(); + + return { + verified: true, + partyRole: role, + message: `${role} party verified and bound to phone ${phone}.`, + }; + } + /** * List all expert-initiated blame files for the current field expert (their panel). * Only files where initiatedBy === current user are returned. @@ -5659,17 +6147,72 @@ export class RequestManagementService { if (!req.expert) req.expert = {} as any; if (!req.expert.decision) req.expert.decision = {} as any; + + // Ensure guilty party is recorded (first party is always guilty in IN_PERSON flow) + const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); + const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; + const guiltyPhone = (req.parties?.[firstIdx]?.person as any)?.phoneNumber; + const secondIdx = this.getPartyIndex(req, PartyRole.SECOND); + const damagedPhone = (req.parties?.[secondIdx]?.person as any)?.phoneNumber; + (req.expert as any).decision = { ...(req.expert as any).decision, + guiltyPartyId: guiltyUserId + ? new Types.ObjectId(String(guiltyUserId)) + : (req.expert as any).decision?.guiltyPartyId, + description: + (req.expert as any).decision?.description || + `مقصر توسط کارشناس در محل حادثه مشخص شد. کاربر ${guiltyPhone ?? ""} مقصر و کاربر ${damagedPhone ?? ""} زیان دیده می‌باشد.`, + decidedAt: (req.expert as any).decision?.decidedAt || new Date(), + decidedByExpertId: new Types.ObjectId(String(expert.sub)), fields: { accidentWay: fields.accidentWay, accidentReason: fields.accidentReason, accidentType: fields.accidentType, }, }; + + // For IN_PERSON expert files: after accident fields are filled, guilt is + // decided and we can go straight to signatures (no separate review needed). + if (req.creationMethod === CreationMethod.IN_PERSON) { + const completed = Array.isArray(req.workflow?.completedSteps) + ? req.workflow.completedSteps + : []; + const currentStep = req.workflow?.currentStep as WorkflowStep | undefined; + if ( + currentStep && + !completed.includes(currentStep) + ) { + completed.push(currentStep); + } + if (!completed.includes(WorkflowStep.WAITING_FOR_GUILT_DECISION)) { + completed.push(WorkflowStep.WAITING_FOR_GUILT_DECISION); + } + if (req.workflow) { + req.workflow.completedSteps = completed; + req.workflow.currentStep = WorkflowStep.WAITING_FOR_SIGNATURES; + req.workflow.nextStep = undefined; + } + req.status = CaseStatus.WAITING_FOR_SIGNATURES; + + if (!Array.isArray(req.history)) req.history = []; + req.history.push({ + type: "ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES", + actor: { + actorId: new Types.ObjectId(String(expert.sub)), + actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), + actorType: "field_expert", + }, + metadata: { + accidentWay: fields.accidentWay, + advancedTo: WorkflowStep.WAITING_FOR_SIGNATURES, + }, + } as any); + } + await (req as any).save(); - return { requestId: req._id }; + return { requestId: req._id, workflow: req.workflow, status: req.status }; } /** From a7849f915acfb7bb38a73e5c38cb98a98b53ffe8 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Mon, 15 Jun 2026 15:58:31 +0330 Subject: [PATCH 3/3] Added registrar mirrored --- .../claim-request-management.module.ts | 2 + .../claim-request-management.service.ts | 22 + .../registrar-claim.mirror.controller.ts | 561 ++++++++++++++++++ src/common/auth/auth.module.ts | 2 + src/common/auth/constants/hash.constants.ts | 8 + src/common/auth/providers/hash.provider.ts | 68 +++ src/common/auth/providers/index.ts | 1 + src/common/auth/types/payload.types.ts | 1 - src/core/config/config.schema.ts | 9 +- ...xpert-initiated-blame.mirror.controller.ts | 54 -- .../registrar-blame.mirror.controller.ts | 418 +++++++++++++ .../request-management.module.ts | 2 + .../request-management.service.ts | 2 +- 13 files changed, 1089 insertions(+), 61 deletions(-) create mode 100644 src/claim-request-management/registrar-claim.mirror.controller.ts create mode 100644 src/common/auth/constants/hash.constants.ts create mode 100644 src/common/auth/providers/hash.provider.ts create mode 100644 src/common/auth/providers/index.ts create mode 100644 src/request-management/registrar-blame.mirror.controller.ts diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index 79e30bc..d2c522f 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -8,6 +8,7 @@ import { ClaimRequestManagementController } from "./claim-request-management.con import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller"; import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller"; import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller"; +import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.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"; @@ -99,6 +100,7 @@ import { HttpModule } from "@nestjs/axios"; ClaimRequestManagementV2Controller, RegistrarClaimV1Controller, ExpertInitiatedClaimMirrorController, + RegistrarClaimMirrorController, ], exports: [ ClaimRequestManagementService, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 4de9656..89e32d3 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -6536,6 +6536,28 @@ export class ClaimRequestManagementService { }, { lean: true }, ); + } else if (actor?.role === RoleEnum.REGISTRAR) { + const registrarBlameIds = await this.blameRequestDbService + .find( + { + registrarInitiated: true, + creationMethod: "IN_PERSON", + initiatedByRegistrarId: new Types.ObjectId(currentUserId), + }, + { select: "_id", lean: true }, + ) + .then((docs) => docs.map((d) => (d as any)._id)); + claims = await this.claimCaseDbService.find( + { + $or: [ + { "owner.userId": new Types.ObjectId(currentUserId) }, + ...(registrarBlameIds.length + ? [{ blameRequestId: { $in: registrarBlameIds } }] + : []), + ], + }, + { lean: true }, + ); } else { claims = await this.claimCaseDbService.find( { "owner.userId": new Types.ObjectId(currentUserId) }, diff --git a/src/claim-request-management/registrar-claim.mirror.controller.ts b/src/claim-request-management/registrar-claim.mirror.controller.ts new file mode 100644 index 0000000..86f1853 --- /dev/null +++ b/src/claim-request-management/registrar-claim.mirror.controller.ts @@ -0,0 +1,561 @@ +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto"; +import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog"; +import { ClaimRequestManagementService } from "./claim-request-management.service"; +import { + OuterPartCatalogItemDto, + SelectOuterPartsV2Dto, + SelectOuterPartsV2ResponseDto, +} from "./dto/select-outer-parts-v2.dto"; +import { + SelectOtherPartsV2Dto, + SelectOtherPartsV2ResponseDto, +} from "./dto/select-other-parts-v2.dto"; +import { + UploadRequiredDocumentV2Dto, + UploadRequiredDocumentV2ResponseDto, +} from "./dto/upload-document-v2.dto"; +import { + CapturePartV2Dto, + CapturePartV2ResponseDto, +} from "./dto/capture-part-v2.dto"; +import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto"; + +/** + * Registrar claim flow that mirrors the normal user claim API + * (`v2/claim-request-management`) one-to-one. The frontend reuses the same + * claim pages by only swapping the route prefix: + * + * `v2/claim-request-management/*` -> `v2/registrar/claim-request-management/*` + * + * The registrar fills the claim (parts, captures, documents) on behalf of the + * damaged party for a claim created from a registrar-initiated IN_PERSON blame. + * After submission the file follows the exact normal review lifecycle: + * the damage expert prices it, and the owner can later object/resend. + * The registrar's job ends here — no reviewing panel is needed. + */ +@ApiTags("registrar claim (mirror v2)") +@Controller("v2/registrar/claim-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.REGISTRAR) +export class RegistrarClaimMirrorController { + constructor( + private readonly claimRequestManagementService: ClaimRequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + @Post("create-from-blame/:blameRequestId") + @ApiParam({ name: "blameRequestId" }) + @ApiOperation({ + summary: "[Registrar mirror] Create claim from registrar-initiated IN_PERSON blame", + description: + "Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.", + }) + async createFromBlame( + @Param("blameRequestId") blameRequestId: string, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1( + blameRequestId, + registrar, + ); + } + + @Get("requests") + @ApiOperation({ summary: "[Registrar mirror] List my (in-person) claims" }) + async getMyClaims( + @CurrentUser() registrar: any, + @Query() query: ListQueryV2Dto, + ) { + return this.claimRequestManagementService.getMyClaimsV2( + registrar.sub, + registrar, + query, + ); + } + + @Get("outer-parts-catalog") + @ApiOperation({ + summary: "Get outer parts catalog (V2)", + description: + "Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.", + }) + @ApiResponse({ + status: 200, + description: "Outer parts catalog", + type: [OuterPartCatalogItemDto], + }) + async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) { + return this.claimRequestManagementService.getOuterPartsCatalogV2(carType); + } + + @Get("car-other-part") + @ApiOperation({ + summary: "Get other (non-body) parts catalog", + description: + "Returns legacy other-parts catalog used by frontend. Response is parsed JSON.", + }) + @ApiResponse({ status: 200, description: "Other parts catalog" }) + async getCarOtherParts() { + const raw = await readFile( + `${process.cwd()}/src/static/car-part.json`, + "utf-8", + ); + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + @Get("branches/:insuranceId") + @ApiParam({ + name: "insuranceId", + description: "Insurer client id (MongoDB ObjectId)", + example: "60d5ec49e7b2f8001c8e4d2a", + }) + @ApiOperation({ + summary: "Get insurer branches (V2)", + description: + "Returns branch list for a given insurer/client id so frontend can render branch options.", + }) + @ApiResponse({ status: 200, description: "List of branches for insurer" }) + async getInsuranceBranches(@Param("insuranceId") insuranceId: string) { + return this.claimRequestManagementService.retrieveInsuranceBranches( + insuranceId, + ); + } + + @Get("request/:claimRequestId") + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiOperation({ summary: "[Registrar mirror] Get claim details" }) + async getClaimDetails( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() registrar: any, + ) { + return this.claimRequestManagementService.getClaimDetailsV2( + claimRequestId, + registrar.sub, + registrar, + ); + } + + @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:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party. + +**After Success:** +- Workflow moves to: SELECT_OTHER_PARTS (Step 3) + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + type: SelectOuterPartsV2Dto, + description: "Selected vehicle type + selected outer part IDs from catalog", + examples: { + example1: { + summary: "Sedan - minor front damage", + value: { carType: "sedan", selectedPartIds: [19, 21, 16] }, + }, + example2: { + summary: "SUV - left side impact", + value: { carType: "suv", selectedPartIds: [102, 103, 104, 107] }, + }, + example3: { + summary: "Hatchback - rear-end collision", + value: { carType: "hatchback", selectedPartIds: [225, 226, 210] }, + }, + example4: { + summary: "Pickup - two sides + roof", + value: { carType: "pickup", selectedPartIds: [319, 312, 330] }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: "Outer parts selected successfully", + type: SelectOuterPartsV2ResponseDto, + }) + @ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" }) + @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() registrar: any, + ): Promise { + return this.claimRequestManagementService.selectOuterPartsV2( + claimRequestId, + body, + registrar.sub, + registrar, + ); + } + + @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:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party. +Optional: upload car green card file in the same step. + +**Valid Other Parts (Optional):** +- engine, suspension, brake_system, electrical +- radiator, transmission, exhaust +- headlight, taillight, mirror, glass + +**After Success:** +- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4) + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID (MongoDB ObjectId)", + example: "507f1f77bcf86cd799439011", + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/claim-required-document", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `other-parts-${unique}${ex}`); + }, + }), + }), + ) + @ApiBody({ + description: + "Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.", + schema: { + type: "object", + properties: { + otherParts: { + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string", description: "JSON string array for multipart" }, + ], + example: ["engine", "suspension"], + }, + sheba: { type: "string", example: "IR123456789012345678901234" }, + nationalCodeOfInsurer: { type: "string", example: "1234567890" }, + file: { type: "string", format: "binary" }, + }, + required: ["sheba", "nationalCodeOfInsurer"], + }, + }) + @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: 404, description: "Claim case not found" }) + @ApiResponse({ status: 409, description: "Bank information already submitted" }) + async selectOtherParts( + @Param("claimRequestId") claimRequestId: string, + @Body() body: SelectOtherPartsV2Dto, + @CurrentUser() registrar: any, + @UploadedFile() file?: Express.Multer.File, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.selectOtherPartsV2( + claimRequestId, + body, + registrar.sub, + registrar, + file, + ); + } + + @Get("capture-requirements/:claimRequestId") + @ApiOperation({ + summary: "Get Capture Requirements (V2)", + description: ` +**Get list of what needs to be captured:** +- Required documents (10 remaining at the documents step for third-party) +- 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: "Access denied" }) + @ApiResponse({ status: 404, description: "Claim case not found" }) + async getCaptureRequirements( + @Param("claimRequestId") claimRequestId: string, + @CurrentUser() registrar: any, + ): Promise { + return this.claimRequestManagementService.getCaptureRequirementsV2( + claimRequestId, + registrar.sub, + registrar, + ); + } + + @Post("upload-document/:claimRequestId") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + 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 5)", + description: ` +**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim) + +**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements): +- 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 (THIRD_PARTY) + +**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE. + `, + }) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + schema: { + type: "object", + required: ["documentKey", "file"], + properties: { + documentKey: { + type: "string", + enum: [ + "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: "damaged_driving_license_front", + }, + 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() registrar: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.uploadRequiredDocumentV2( + claimRequestId, + body, + file, + registrar.sub, + registrar, + ); + } + + @Post("capture-part/:claimRequestId") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + 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 4)", + description: ` +**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim) + +**Capture types:** +1. **angle**: Car angles (front, back, left, right) - 4 required +2. **part**: Damaged parts based on selectedParts from Step 2 + +**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). + `, + }) + @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() registrar: any, + ): Promise { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image"); + return this.claimRequestManagementService.capturePartV2( + claimRequestId, + body, + file, + registrar.sub, + registrar, + ); + } + + @Patch("car-capture/:claimRequestId") + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/car-capture-videos/", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `claim-video-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ + name: "claimRequestId", + description: "The claim case ID", + example: "507f1f77bcf86cd799439011", + }) + @ApiBody({ + schema: { + type: "object", + required: ["file"], + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiOperation({ + summary: "Upload Car Walk-Around Video (V2 - Step 4b)", + description: + "Upload a walk-around video of the damaged car during the capture phase.", + }) + @ApiResponse({ status: 200, description: "Video uploaded successfully" }) + async carCapture( + @Param("claimRequestId") claimRequestId: string, + @UploadedFile("file") file: Express.Multer.File, + @CurrentUser() registrar: any, + ) { + await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video"); + return this.claimRequestManagementService.setVideoCaptureV2( + claimRequestId, + file, + registrar.sub, + registrar, + ); + } +} diff --git a/src/common/auth/auth.module.ts b/src/common/auth/auth.module.ts index fb596c5..b4bf1b9 100644 --- a/src/common/auth/auth.module.ts +++ b/src/common/auth/auth.module.ts @@ -6,6 +6,7 @@ import { StringValue } from "ms"; import { AuthGuard, RolesGuard } from "./guards"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { HashService } from "./providers"; @Module({ imports: [ @@ -25,6 +26,7 @@ import { AuthService } from "./auth.service"; { provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, AuthService, + HashService, ], }) export class AuthModule {} diff --git a/src/common/auth/constants/hash.constants.ts b/src/common/auth/constants/hash.constants.ts new file mode 100644 index 0000000..a01f4a2 --- /dev/null +++ b/src/common/auth/constants/hash.constants.ts @@ -0,0 +1,8 @@ +import { ScryptOptions } from "node:crypto"; + +export const KEY_LENGTH = 64; +export const SCRYPT_PARAMS: ScryptOptions = { + N: 19456, // CPU/memory cost + r: 8, // block size + p: 1, // parallelization +}; diff --git a/src/common/auth/providers/hash.provider.ts b/src/common/auth/providers/hash.provider.ts new file mode 100644 index 0000000..b77f88d --- /dev/null +++ b/src/common/auth/providers/hash.provider.ts @@ -0,0 +1,68 @@ +import { + BinaryLike, + randomBytes, + scrypt, + ScryptOptions, + timingSafeEqual, +} from "node:crypto"; +import { promisify } from "node:util"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { KEY_LENGTH, SCRYPT_PARAMS } from "../constants/hash.constants"; + +const scryptAsync = promisify< + BinaryLike, + BinaryLike, + number, + ScryptOptions, + Buffer +>(scrypt); + +@Injectable() +export class HashService { + private readonly pepper: string; + + constructor(private readonly configService: ConfigService) { + this.pepper = this.configService.get("HASH_PEPPER"); + } + + private applyPepper(input: string): string { + return this.pepper ? `${input}${this.pepper}` : input; + } + + private generateSalt(bytes: number = 16): string { + return randomBytes(bytes).toString("hex"); + } + + private async scrypt(password: string): Promise<[string, string]> { + const salt = this.generateSalt(); + const pepperedInput = this.applyPepper(password); + const hashedPassword = await scryptAsync( + pepperedInput, + salt, + KEY_LENGTH, + SCRYPT_PARAMS, + ); + + return [salt, hashedPassword.toString("hex")]; + } + + private async verifyScrypt( + password: string, + salt: string, + hashedPassword: string, + ): Promise { + const pepperedInput = this.applyPepper(password); + const derived = await scryptAsync( + pepperedInput, + salt, + KEY_LENGTH, + SCRYPT_PARAMS, + ); + const storedBuffer = Buffer.from(hashedPassword, "hex"); + + if (derived.length !== storedBuffer.length) return false; + + return timingSafeEqual(derived, storedBuffer); + } +} diff --git a/src/common/auth/providers/index.ts b/src/common/auth/providers/index.ts new file mode 100644 index 0000000..fd681a4 --- /dev/null +++ b/src/common/auth/providers/index.ts @@ -0,0 +1 @@ +export { HashService } from "./hash.provider"; diff --git a/src/common/auth/types/payload.types.ts b/src/common/auth/types/payload.types.ts index a939f9b..b7418e1 100644 --- a/src/common/auth/types/payload.types.ts +++ b/src/common/auth/types/payload.types.ts @@ -1,7 +1,6 @@ export interface JwtPayload { sub: string; role: string; - clientId?: string; iat?: number; exp?: number; } diff --git a/src/core/config/config.schema.ts b/src/core/config/config.schema.ts index 1e6753a..3f414d5 100644 --- a/src/core/config/config.schema.ts +++ b/src/core/config/config.schema.ts @@ -2,6 +2,7 @@ import { IsBooleanString, IsEnum, IsNumber, + IsOptional, IsPositive, IsString, IsUrl, @@ -79,10 +80,8 @@ export class EnvironmentVariables { JWT_EXPIRY: StringValue; // --------------------------------------------------------- // - // @IsString({ message: "HASH_PEPPER must be string" }) - // @MinLength(32, { - // message: "HASH_PEPPER must be at least 32 characters", - // }) - // HASH_PEPPER: string; + @IsString({ message: "HASH_PEPPER must be string" }) + @IsOptional() + HASH_PEPPER?: string; // --------------------------------------------------------- // } diff --git a/src/request-management/expert-initiated-blame.mirror.controller.ts b/src/request-management/expert-initiated-blame.mirror.controller.ts index bc72519..6b43b49 100644 --- a/src/request-management/expert-initiated-blame.mirror.controller.ts +++ b/src/request-management/expert-initiated-blame.mirror.controller.ts @@ -150,60 +150,6 @@ export class ExpertInitiatedBlameMirrorController { ); } - // @Post("send-party-otps/:requestId") - // @ApiParam({ name: "requestId" }) - // @ApiBody({ type: SendPartyOtpsDto }) - // @ApiOperation({ - // summary: "[Expert mirror] Send OTP to party/parties (IN_PERSON)", - // description: - // "Bind the parties' user accounts before filling their data. Required so guilt and the claim owner can be resolved.", - // }) - // async sendPartyOtps( - // @CurrentUser() expert: any, - // @Param("requestId") requestId: string, - // @Body() dto: SendPartyOtpsDto, - // ) { - // return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto); - // } - - // @Post("verify-party-otps/:requestId") - // @ApiParam({ name: "requestId" }) - // @ApiBody({ type: VerifyPartyOtpsDto }) - // @ApiOperation({ summary: "[Expert mirror] Verify party OTPs (IN_PERSON)" }) - // async verifyPartyOtps( - // @CurrentUser() expert: any, - // @Param("requestId") requestId: string, - // @Body() dto: VerifyPartyOtpsDto, - // ) { - // return this.requestManagementService.verifyPartyOtpsV2( - // expert, - // requestId, - // dto, - // ); - // } - - // @Post("/blame-confession/:requestId") - // @ApiParam({ name: "requestId" }) - // @ApiBody({ type: BlameConfessionDtoV2 }) - // @ApiOperation({ - // summary: "[Expert mirror] First-party blame confession", - // description: - // "THIRD_PARTY step. The FIRST party is the guilty party, so send imGuilty=true.", - // }) - // async blameConfession( - // @Param("requestId") requestId: string, - // @Body() body: BlameConfessionDtoV2, - // @CurrentUser() expert: any, - // @Body("partyRole") partyRole?: string, - // ) { - // return this.requestManagementService.blameConfessionV2( - // requestId, - // body, - // expert, - // partyRole, - // ); - // } - @Post("/car-body-form/:requestId") @ApiParam({ name: "requestId" }) @ApiBody({ type: CarBodyFormDto }) diff --git a/src/request-management/registrar-blame.mirror.controller.ts b/src/request-management/registrar-blame.mirror.controller.ts new file mode 100644 index 0000000..c605e30 --- /dev/null +++ b/src/request-management/registrar-blame.mirror.controller.ts @@ -0,0 +1,418 @@ +import { extname } from "node:path"; +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Put, + UploadedFile, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiTags, +} from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +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 { MediaPolicyService } from "src/media-policy/media-policy.service"; +import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service"; +import { RoleEnum } from "src/Types&Enums/role.enum"; +import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; +import { + CarBodyFormDto, + DescriptionDto, + LocationDto, +} from "./dto/create-request-management.dto"; +import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto"; +import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto"; +import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto"; +import { RequestManagementService } from "./request-management.service"; +import { PartyRole } from "./entities/schema/partyRole.enum"; + +/** + * Registrar blame flow that mirrors the normal user blame API + * (`v2/blame-request-management`) one-to-one, using only the IN_PERSON method. + * The frontend can reuse the same pages by only swapping the route prefix: + * + * `v2/blame-request-management/*` -> `v2/registrar/blame-request-management/*` + * + * The registrar fills every step on behalf of both parties using the exact same + * endpoints/bodies as a normal user. By contract the FIRST party registered is + * always the guilty party. There is no LINK flow and no reviewing phase for the + * registrar — the job is finished once the file is signed. + * + * Sequence: create -> send/verify OTP (first party) -> first party steps + * (car-body-form or initial-form, upload-video, add-detail-location, + * upload-voice, add-detail-description) -> add-second-party -> + * send/verify OTP (second party) -> second party steps -> + * add-accident-fields -> sign FIRST + sign SECOND -> COMPLETED. + */ +@ApiTags("registrar blame (mirror v2)") +@Controller("v2/registrar/blame-request-management") +@ApiBearerAuth() +@UseGuards(LocalActorAuthGuard, RolesGuard) +@Roles(RoleEnum.REGISTRAR) +export class RegistrarBlameMirrorController { + constructor( + private readonly requestManagementService: RequestManagementService, + private readonly mediaPolicyService: MediaPolicyService, + ) {} + + @Post() + @ApiOperation({ + summary: "[Registrar mirror] Create registrar-initiated blame file", + description: + "Always IN_PERSON. Creates a BlameRequest owned by the parties but filled by the registrar. FIRST party registered is always the guilty party.", + }) + @ApiBody({ type: CreateRegistrarInitiatedFileDto }) + async create( + @CurrentUser() registrar: any, + @Body() dto: CreateRegistrarInitiatedFileDto, + ) { + return this.requestManagementService.createRegistrarInitiatedBlame( + registrar, + dto, + ); + } + + @Get() + @ApiOperation({ + summary: "[Registrar mirror] List my registrar-initiated blame files", + }) + async list(@CurrentUser() registrar: any) { + return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar); + } + + @Post("send-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: SendPartyOtpDto }) + @ApiOperation({ + summary: "[Registrar mirror] Send OTP to one party (sequential IN_PERSON)", + description: + "Sends an OTP SMS to a single party's phone number. Flow: send to first party → verify → fill data → send to second party → verify → continue. No invite link is sent.", + }) + async sendPartyOtp( + @CurrentUser() registrar: any, + @Param("requestId") requestId: string, + @Body() dto: SendPartyOtpDto, + ) { + return this.requestManagementService.sendPartyOtpV2( + registrar, + requestId, + dto, + ); + } + + @Post("verify-party-otp/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: VerifyPartyOtpDto }) + @ApiOperation({ + summary: "[Registrar mirror] Verify one party's OTP (sequential IN_PERSON)", + description: + "Verifies a single party's OTP and binds their account. `partyRole` is optional (inferred as FIRST until the first party is bound, then SECOND).", + }) + async verifyPartyOtp( + @CurrentUser() registrar: any, + @Param("requestId") requestId: string, + @Body() dto: VerifyPartyOtpDto, + ) { + return this.requestManagementService.verifyPartyOtpV2( + registrar, + requestId, + dto, + ); + } + + @Post("/car-body-form/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: CarBodyFormDto }) + @ApiOperation({ summary: "[Registrar mirror] CAR_BODY accident type form" }) + async carBodyForm( + @Param("requestId") requestId: string, + @Body() body: CarBodyFormDto, + @CurrentUser() registrar: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.carBodyAccidentTypeFormV2( + requestId, + body, + registrar, + partyRole, + ); + } + + @Post("/initial-form/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: AddPlateDto }) + @ApiOperation({ + summary: "[Registrar mirror] Initial form (plate/insurance) for current party", + description: + "Fills the FIRST or SECOND party plate/insurance/vehicle data depending on the current workflow step.", + }) + async initialForm( + @Param("requestId") requestId: string, + @Body() body: AddPlateDto, + @CurrentUser() registrar: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.initialFormV2( + requestId, + body, + registrar, + partyRole, + ); + } + + @ApiBody({ + schema: { + type: "object", + properties: { file: { type: "string", format: "binary" } }, + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/video", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + callback(null, `registrar-${file.originalname}-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ name: "requestId" }) + @Post("upload-video/:requestId") + @ApiOperation({ summary: "[Registrar mirror] Upload first-party video" }) + async uploadVideo( + @Param("requestId") requestId: string, + @CurrentUser() registrar: any, + @UploadedFile() file?: Express.Multer.File, + ) { + await this.mediaPolicyService.assertForBlame(file, requestId, "video"); + return this.requestManagementService.uploadFirstPartyVideoV2( + requestId, + file, + registrar, + ); + } + + @Post("/add-detail-location/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: LocationDto }) + @ApiOperation({ summary: "[Registrar mirror] Add location for current party" }) + async addLocation( + @Param("requestId") requestId: string, + @Body() body: LocationDto, + @CurrentUser() registrar: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addDetailLocationV2( + requestId, + body, + registrar, + partyRole, + ); + } + + @ApiBody({ + schema: { + type: "object", + properties: { + file: { type: "string", format: "binary" }, + partyRole: { + type: "string", + enum: ["FIRST", "SECOND"], + description: + "Optional explicit party selector; must match the party currently collecting voice.", + }, + }, + }, + }) + @ApiConsumes("multipart/form-data") + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/voice", + filename: (req, file, callback) => { + const unique = Date.now(); + const ex = extname(file.originalname); + const flname = file.originalname.split(".")[0]; + callback(null, `registrar-${flname}-${unique}${ex}`); + }, + }), + }), + ) + @ApiParam({ name: "requestId" }) + @Post("upload-voice/:requestId") + @ApiOperation({ summary: "[Registrar mirror] Upload voice for current party" }) + async uploadVoice( + @Param("requestId") requestId: string, + @CurrentUser() registrar: any, + @UploadedFile() voice?: Express.Multer.File, + @Body("partyRole") partyRole?: string, + ) { + await this.mediaPolicyService.assertForBlame(voice, requestId, "voice"); + return this.requestManagementService.uploadVoiceV2( + requestId, + voice, + registrar, + partyRole, + ); + } + + @Post("/add-detail-description/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: DescriptionDto }) + @ApiOperation({ + summary: "[Registrar mirror] Add description for current party", + description: + "For THIRD_PARTY, submitting the SECOND party description finalizes guilt (FIRST party = guilty) and moves the file straight to WAITING_FOR_SIGNATURES.", + }) + async addDescription( + @Param("requestId") requestId: string, + @Body() body: DescriptionDto, + @CurrentUser() registrar: any, + @Body("partyRole") partyRole?: string, + ) { + return this.requestManagementService.addDescriptionV2( + requestId, + body, + registrar, + partyRole, + ); + } + + @Post("add-second-party/:phoneNumber/:requestId/") + @ApiParam({ name: "phoneNumber" }) + @ApiParam({ name: "requestId" }) + @ApiOperation({ + summary: "[Registrar mirror] Advance to second party (sends OTP, no link)", + description: + "Sends an OTP SMS to the second party's phone number and updates the workflow to await verification. No invite link is sent. Use verify-party-otp next.", + }) + async addSecondParty( + @Param("phoneNumber") phoneNumber: string, + @Param("requestId") requestId: string, + @CurrentUser() registrar: any, + ) { + return this.requestManagementService.expertAdvanceToSecondPartyV2( + requestId, + registrar, + phoneNumber, + ); + } + + @Post("add-accident-fields/:requestId") + @ApiParam({ name: "requestId" }) + @ApiBody({ type: ExpertAccidentFieldsDto }) + @ApiOperation({ + summary: "[Registrar mirror] Add accident fields and advance to signatures", + description: + "Saves accidentWay, accidentReason, and accidentType. " + + "The first party is always guilty, so this also advances the workflow " + + "from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES. " + + "Must be called before sign.", + }) + async addAccidentFields( + @Param("requestId") requestId: string, + @Body() fields: ExpertAccidentFieldsDto, + @CurrentUser() registrar: any, + ) { + return this.requestManagementService.expertAddAccidentFieldsForBlameV2( + registrar, + requestId, + fields, + ); + } + + @Put("sign/:requestId") + @ApiParam({ name: "requestId" }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "[Registrar mirror] Upload a party's on-site signature", + description: + "Collect each party's signature on site. CAR_BODY: partyRole=FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed (accepted), the blame case completes.", + }) + @ApiBody({ + schema: { + type: "object", + required: ["partyRole", "sign"], + properties: { + partyRole: { type: "string", enum: ["FIRST", "SECOND"] }, + isAccept: { type: "boolean", default: true }, + sign: { type: "string", format: "binary" }, + }, + }, + }) + @UseInterceptors( + FileInterceptor("sign", { + limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES }, + storage: diskStorage({ + destination: "./files/signs", + filename: (req, file, callback) => { + const unique = Date.now(); + const ext = extname(file.originalname); + callback(null, `registrar-party-${unique}${ext}`); + }, + }), + }), + ) + async sign( + @Param("requestId") requestId: string, + @Body() body: { partyRole?: string; isAccept?: string | boolean }, + @CurrentUser() registrar: any, + @UploadedFile() sign: Express.Multer.File, + ) { + // Guard: accident fields must be filled before signing to prevent the + // double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES. + const requestData = await this.requestManagementService.getBlameRequestV2( + requestId, + registrar, + ); + if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) { + throw new BadRequestException( + "Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.", + ); + } + + await this.mediaPolicyService.assertForBlame(sign, requestId, "image"); + const partyRole = + body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST; + const isAccept = !(body?.isAccept === false || body?.isAccept === "false"); + return this.requestManagementService.expertUploadPartySignatureV2( + registrar, + requestId, + partyRole, + isAccept, + sign, + ); + } + + @Get(":requestId") + @ApiParam({ name: "requestId" }) + @ApiOperation({ summary: "[Registrar mirror] Get one blame request" }) + async getOne( + @Param("requestId") requestId: string, + @CurrentUser() registrar: any, + ) { + return this.requestManagementService.getBlameRequestV2( + requestId, + registrar, + ); + } +} diff --git a/src/request-management/request-management.module.ts b/src/request-management/request-management.module.ts index 70af222..c2b4f08 100644 --- a/src/request-management/request-management.module.ts +++ b/src/request-management/request-management.module.ts @@ -41,6 +41,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller"; import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller"; import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller"; import { RegistrarInitiatedController } from "./registrar-initiated.controller"; +import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller"; @Module({ imports: [ @@ -75,6 +76,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller"; ExpertInitiatedV2Controller, ExpertInitiatedBlameMirrorController, RegistrarInitiatedController, + RegistrarBlameMirrorController, ], providers: [ RequestManagementService, diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 118e5b6..cfdeb38 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -1424,7 +1424,7 @@ export class RequestManagementService { status: req.status, }; } catch (error) { - console.log(error); + throw error; } }