forked from Yara724/api
Added expert field mirror flow
This commit is contained in:
@@ -150,7 +150,7 @@ export class ActorAuthController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Actor login (returns access + refresh tokens)",
|
summary: "Actor login (returns access + refresh tokens)",
|
||||||
description:
|
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({
|
@ApiBody({
|
||||||
type: LoginActorDto,
|
type: LoginActorDto,
|
||||||
@@ -159,7 +159,8 @@ export class ActorAuthController {
|
|||||||
examples: {
|
examples: {
|
||||||
company: {
|
company: {
|
||||||
summary: "Insurer / company portal",
|
summary: "Insurer / company portal",
|
||||||
description: "Sample tenant credentials for the insurer (company) panel.",
|
description:
|
||||||
|
"Sample tenant credentials for the insurer (company) panel.",
|
||||||
value: {
|
value: {
|
||||||
role: "company",
|
role: "company",
|
||||||
username: "saman_insurer@gmail.com",
|
username: "saman_insurer@gmail.com",
|
||||||
@@ -190,6 +191,17 @@ export class ActorAuthController {
|
|||||||
captcha: "a7bx2",
|
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({
|
@ApiResponse({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UsersModule } from "src/users/users.module";
|
|||||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||||
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||||
|
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||||
@@ -97,6 +98,7 @@ import { HttpModule } from "@nestjs/axios";
|
|||||||
ClaimRequestManagementController,
|
ClaimRequestManagementController,
|
||||||
ClaimRequestManagementV2Controller,
|
ClaimRequestManagementV2Controller,
|
||||||
RegistrarClaimV1Controller,
|
RegistrarClaimV1Controller,
|
||||||
|
ExpertInitiatedClaimMirrorController,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ClaimRequestManagementService,
|
ClaimRequestManagementService,
|
||||||
|
|||||||
@@ -4120,6 +4120,7 @@ export class ClaimRequestManagementService {
|
|||||||
publicId: blameRequest.publicId,
|
publicId: blameRequest.publicId,
|
||||||
blameRequestId: new Types.ObjectId(blameRequestId),
|
blameRequestId: new Types.ObjectId(blameRequestId),
|
||||||
blameRequestNo: blameRequest.requestNo,
|
blameRequestNo: blameRequest.requestNo,
|
||||||
|
initiatedByFieldExpertId: new Types.ObjectId(expert.sub),
|
||||||
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||||
claimStatus: ClaimStatus.PENDING,
|
claimStatus: ClaimStatus.PENDING,
|
||||||
inquiries: (blameRequest as any).inquiries ?? {},
|
inquiries: (blameRequest as any).inquiries ?? {},
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ export class ClaimCase {
|
|||||||
@Prop({ type: String, index: true })
|
@Prop({ type: String, index: true })
|
||||||
blameRequestNo?: string;
|
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: () => ({}) })
|
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
||||||
workflow?: ClaimWorkflow;
|
workflow?: ClaimWorkflow;
|
||||||
|
|
||||||
|
|||||||
@@ -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<SelectOuterPartsV2ResponseDto> {
|
||||||
|
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<SelectOtherPartsV2ResponseDto> {
|
||||||
|
// 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<GetCaptureRequirementsV2ResponseDto> {
|
||||||
|
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<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
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<CapturePartV2ResponseDto> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/common/auth/auth.controller.ts
Normal file
21
src/common/auth/auth.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/common/auth/auth.module.ts
Normal file
30
src/common/auth/auth.module.ts
Normal file
@@ -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<string>("JWT_SECRET"),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<StringValue>("JWT_EXPIRY"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||||
|
AuthService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
31
src/common/auth/auth.service.ts
Normal file
31
src/common/auth/auth.service.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/common/auth/decorators/index.ts
Normal file
2
src/common/auth/decorators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { IS_PUBLIC_KEY, Public } from "./public.decorator";
|
||||||
|
export { ROLES_KEY, Roles } from "./roles.decorator";
|
||||||
4
src/common/auth/decorators/public.decorator.ts
Normal file
4
src/common/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = "isPublic";
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/common/auth/decorators/roles.decorator.ts
Normal file
5
src/common/auth/decorators/roles.decorator.ts
Normal file
@@ -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);
|
||||||
2
src/common/auth/dtos/index.ts
Normal file
2
src/common/auth/dtos/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { UserRegisterDto } from "./user-register.dto";
|
||||||
|
export { UserLoginDto } from "./user-login.dto";
|
||||||
10
src/common/auth/dtos/user-login.dto.ts
Normal file
10
src/common/auth/dtos/user-login.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
7
src/common/auth/dtos/user-register.dto.ts
Normal file
7
src/common/auth/dtos/user-register.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
1
src/common/auth/enums/index.ts
Normal file
1
src/common/auth/enums/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Role } from "./roles.enum";
|
||||||
9
src/common/auth/enums/roles.enum.ts
Normal file
9
src/common/auth/enums/roles.enum.ts
Normal file
@@ -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",
|
||||||
|
}
|
||||||
35
src/common/auth/guards/auth.guard.ts
Normal file
35
src/common/auth/guards/auth.guard.ts
Normal file
@@ -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<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException("No token provided");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload: JwtPayload =
|
||||||
|
await this.jwtService.verifyAsync<JwtPayload>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/common/auth/guards/index.ts
Normal file
2
src/common/auth/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AuthGuard } from "./auth.guard";
|
||||||
|
export { RolesGuard } from "./role.guard";
|
||||||
47
src/common/auth/guards/role.guard.ts
Normal file
47
src/common/auth/guards/role.guard.ts
Normal file
@@ -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<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/common/auth/types/index.ts
Normal file
1
src/common/auth/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { JwtPayload } from "./payload.types";
|
||||||
7
src/common/auth/types/payload.types.ts
Normal file
7
src/common/auth/types/payload.types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
role: string;
|
||||||
|
clientId?: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
@@ -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 { 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 { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import {
|
import {
|
||||||
assertClaimCaseForTenant,
|
assertClaimCaseForTenant,
|
||||||
|
assertClaimCaseForExpertActor,
|
||||||
|
claimCaseInitiatedByFieldExpert,
|
||||||
claimCaseTouchesClient,
|
claimCaseTouchesClient,
|
||||||
requireActorClientKey,
|
requireActorClientKey,
|
||||||
} from "src/helpers/tenant-scope";
|
} from "src/helpers/tenant-scope";
|
||||||
@@ -2148,7 +2151,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
|
|
||||||
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
|
const factorValidationSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||||
|
|
||||||
@@ -2389,9 +2392,9 @@ export class ExpertClaimService {
|
|||||||
*/
|
*/
|
||||||
async assignClaimForReviewV2(
|
async assignClaimForReviewV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
actor: { sub: string; fullName?: string; clientKey?: string; role?: string },
|
||||||
): Promise<ExpertFileAssignResultDto> {
|
): Promise<ExpertFileAssignResultDto> {
|
||||||
requireActorClientKey(actor);
|
if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
|
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
@@ -2399,7 +2402,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
|
|
||||||
const isFactorValidationLock =
|
const isFactorValidationLock =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||||
@@ -2692,7 +2695,7 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -2850,14 +2853,14 @@ export class ExpertClaimService {
|
|||||||
reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto,
|
reply: import("./dto/expert-claim-v2.dto").SubmitExpertReplyV2Dto,
|
||||||
actor: any,
|
actor: any,
|
||||||
) {
|
) {
|
||||||
requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
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.
|
* Does not include the open tenant queue (`WAITING_FOR_DAMAGE_EXPERT`) unless this expert touched the file.
|
||||||
*/
|
*/
|
||||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||||
|
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 clientKey = requireActorClientKey(actor);
|
||||||
const expertId = String(actor.sub);
|
const expertId = String(actor.sub);
|
||||||
const expertOid = new Types.ObjectId(expertId);
|
const expertOid = new Types.ObjectId(expertId);
|
||||||
@@ -3290,6 +3315,9 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
query: ListQueryV2Dto = {},
|
query: ListQueryV2Dto = {},
|
||||||
): Promise<GetClaimListV2ResponseDto> {
|
): Promise<GetClaimListV2ResponseDto> {
|
||||||
|
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||||
|
return this.getFieldExpertClaimListV2(actor, query);
|
||||||
|
}
|
||||||
requireActorClientKey(actor);
|
requireActorClientKey(actor);
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
const clientKey = actor.clientKey as string;
|
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<GetClaimListV2ResponseDto> {
|
||||||
|
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<string, any>(
|
||||||
|
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`:
|
* Load linked blame case for damage-expert claim detail (same enrichment as expert-blame `findOneV2`:
|
||||||
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
|
* party evidence `videoUrl` / `voiceUrls`, Jalali date strings).
|
||||||
@@ -3668,7 +3786,9 @@ export class ExpertClaimService {
|
|||||||
private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: {
|
private async reconcileStaleExpertReviewingClaimLocksForTenant(actor: {
|
||||||
sub: string;
|
sub: string;
|
||||||
clientKey?: string;
|
clientKey?: string;
|
||||||
|
role?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
||||||
const clientKey = requireActorClientKey(actor);
|
const clientKey = requireActorClientKey(actor);
|
||||||
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -3828,7 +3948,9 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
|
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||||||
|
}
|
||||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
@@ -3836,13 +3958,22 @@ export class ExpertClaimService {
|
|||||||
throw new NotFoundException("Claim request not found");
|
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 =
|
const isDamageExpertPhase =
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT ||
|
||||||
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
claim.status === ClaimCaseStatus.EXPERT_REVIEWING;
|
||||||
const isFactorValidationPending =
|
const isFactorValidationPending =
|
||||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
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 =
|
const isResendPending =
|
||||||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||||
|
|
||||||
@@ -3891,6 +4022,7 @@ export class ExpertClaimService {
|
|||||||
"You do not have permission to view this claim.",
|
"You do not have permission to view this claim.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} // end damage-expert gate (else block)
|
||||||
|
|
||||||
// Build requiredDocuments map
|
// Build requiredDocuments map
|
||||||
const requiredDocs = claim.requiredDocuments as any;
|
const requiredDocs = claim.requiredDocuments as any;
|
||||||
@@ -4134,8 +4266,8 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
/** V2 price-drop: claim locked by this expert during damage assessment. */
|
||||||
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
|
private assertExpertCanEditClaimDuringReviewV2(claim: any, actor: any): void {
|
||||||
requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
`Claim must be EXPERT_REVIEWING to edit price drop. Current status: ${claim.status}`,
|
||||||
@@ -4364,12 +4496,12 @@ export class ExpertClaimService {
|
|||||||
body: UpdateClaimDamagedPartsV2Dto,
|
body: UpdateClaimDamagedPartsV2Dto,
|
||||||
actor: any,
|
actor: any,
|
||||||
) {
|
) {
|
||||||
requireActorClientKey(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException("Claim request not found");
|
throw new NotFoundException("Claim request not found");
|
||||||
}
|
}
|
||||||
assertClaimCaseForTenant(claim, actor);
|
assertClaimCaseForExpertActor(claim, actor);
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class InPersonVisitV2Dto {
|
|||||||
@Controller("v2/expert-claim")
|
@Controller("v2/expert-claim")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT)
|
||||||
export class ExpertClaimV2Controller {
|
export class ExpertClaimV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly expertClaimService: ExpertClaimService,
|
private readonly expertClaimService: ExpertClaimService,
|
||||||
|
|||||||
1
src/features/user/repositories/index.ts
Normal file
1
src/features/user/repositories/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { UserRepository } from "./user.repository";
|
||||||
22
src/features/user/repositories/user.repository.ts
Normal file
22
src/features/user/repositories/user.repository.ts
Normal file
@@ -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<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async addUser() {}
|
||||||
|
|
||||||
|
async retrieveByCellPhoneNumber(
|
||||||
|
cellphoneNumber: string,
|
||||||
|
): Promise<User | null> {
|
||||||
|
return await this.model.findOne({
|
||||||
|
cellphoneNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/features/user/schemas/index.ts
Normal file
3
src/features/user/schemas/index.ts
Normal file
@@ -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";
|
||||||
24
src/features/user/schemas/otp.schema.ts
Normal file
24
src/features/user/schemas/otp.schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument } from "mongoose";
|
||||||
|
|
||||||
|
export type OtpDocument = HydratedDocument<Otp>;
|
||||||
|
|
||||||
|
@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);
|
||||||
18
src/features/user/schemas/profile.schema.ts
Normal file
18
src/features/user/schemas/profile.schema.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument } from "mongoose";
|
||||||
|
|
||||||
|
export type ProfileDocument = HydratedDocument<Profile>;
|
||||||
|
|
||||||
|
@Schema({ _id: false })
|
||||||
|
export class Profile {
|
||||||
|
@Prop()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@Prop()
|
||||||
|
birthDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileSchema = SchemaFactory.createForClass(Profile);
|
||||||
22
src/features/user/schemas/user.schema.ts
Normal file
22
src/features/user/schemas/user.schema.ts
Normal file
@@ -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<User>;
|
||||||
|
|
||||||
|
@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);
|
||||||
0
src/features/user/user.controller.ts
Normal file
0
src/features/user/user.controller.ts
Normal file
10
src/features/user/user.module.ts
Normal file
10
src/features/user/user.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { UserRepository } from "./repositories";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
controllers: [],
|
||||||
|
providers: [UserRepository],
|
||||||
|
exports: [UserRepository],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
@@ -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 {
|
export function resolveGuiltyPartyClientId(doc: any): string | null {
|
||||||
const parties: any[] = doc?.parties ?? [];
|
const parties: any[] = doc?.parties ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -18,22 +18,4 @@ export class CreateExpertInitiatedFileDto {
|
|||||||
})
|
})
|
||||||
@IsEnum(CreationMethod)
|
@IsEnum(CreationMethod)
|
||||||
creationMethod: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
src/request-management/dto/party-otp.dto.ts
Normal file
45
src/request-management/dto/party-otp.dto.ts
Normal file
@@ -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";
|
||||||
|
}
|
||||||
@@ -3,11 +3,10 @@ import { IsNotEmpty, IsString } from "class-validator";
|
|||||||
|
|
||||||
export class SendExpertInitiatedLinkV2Dto {
|
export class SendExpertInitiatedLinkV2Dto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description: "First party phone number. The link SMS will be sent to this number.",
|
||||||
"Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty",
|
example: "09123456789",
|
||||||
example: "requestManagement/firstParty",
|
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
frontendRoute: string;
|
phoneNumber: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto
|
|||||||
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
||||||
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||||
import { SendPartyOtpsDto } from "./dto/send-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 { 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 { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
@@ -158,34 +158,12 @@ export class ExpertInitiatedV2Controller {
|
|||||||
|
|
||||||
@Post("send-link/:requestId")
|
@Post("send-link/:requestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "[V2] Send blame link to party/parties (LINK)",
|
summary: "[V2] Send blame link to first party (LINK)",
|
||||||
description:
|
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" })
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
@ApiBody({ type: SendExpertInitiatedLinkV2Dto })
|
@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(
|
async sendLinkV2(
|
||||||
@CurrentUser() expert: any,
|
@CurrentUser() expert: any,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { RequestManagementController } from "./request-management.controller";
|
|||||||
import { RequestManagementV2Controller } from "./request-management.v2.controller";
|
import { RequestManagementV2Controller } from "./request-management.v2.controller";
|
||||||
import { ExpertInitiatedController } from "./expert-initiated.controller";
|
import { ExpertInitiatedController } from "./expert-initiated.controller";
|
||||||
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
||||||
|
import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.mirror.controller";
|
||||||
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -72,6 +73,7 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
|||||||
RequestManagementV2Controller,
|
RequestManagementV2Controller,
|
||||||
ExpertInitiatedController,
|
ExpertInitiatedController,
|
||||||
ExpertInitiatedV2Controller,
|
ExpertInitiatedV2Controller,
|
||||||
|
ExpertInitiatedBlameMirrorController,
|
||||||
RegistrarInitiatedController,
|
RegistrarInitiatedController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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
|
const partyUserId = party?.person?.userId
|
||||||
? String(party.person.userId)
|
? String(party.person.userId)
|
||||||
: null;
|
: null;
|
||||||
@@ -192,6 +219,30 @@ export class RequestManagementService {
|
|||||||
if (!ok) throw new ForbiddenException(errMsg);
|
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(
|
private async advanceWorkflowToNext(
|
||||||
req: any,
|
req: any,
|
||||||
submittedStepKey: WorkflowStep,
|
submittedStepKey: WorkflowStep,
|
||||||
@@ -610,6 +661,7 @@ export class RequestManagementService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean },
|
body: { imGuilty?: boolean; imDamaged?: boolean; expertOpinion?: boolean },
|
||||||
user: any,
|
user: any,
|
||||||
|
expectedRole?: string,
|
||||||
) {
|
) {
|
||||||
const step2 = await this.getWorkflowStep({ stepNumber: 2 });
|
const step2 = await this.getWorkflowStep({ stepNumber: 2 });
|
||||||
const step2Key = step2.stepKey as WorkflowStep;
|
const step2Key = step2.stepKey as WorkflowStep;
|
||||||
@@ -653,13 +705,23 @@ export class RequestManagementService {
|
|||||||
(firstParty?.person?.phoneNumber &&
|
(firstParty?.person?.phoneNumber &&
|
||||||
firstParty.person.phoneNumber === user?.username);
|
firstParty.person.phoneNumber === user?.username);
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
|
||||||
throw new ForbiddenException("Only first party can submit this step");
|
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
|
// Set userId for first party if not already set
|
||||||
if (!firstParty.person) firstParty.person = {} as any;
|
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)
|
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -755,6 +817,7 @@ export class RequestManagementService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
body: { car?: boolean; object?: boolean },
|
body: { car?: boolean; object?: boolean },
|
||||||
user: any,
|
user: any,
|
||||||
|
expectedRole?: string,
|
||||||
) {
|
) {
|
||||||
const req = await this.blameRequestDbService.findById(requestId);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Request not found");
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
@@ -763,12 +826,17 @@ export class RequestManagementService {
|
|||||||
"This endpoint is only for CAR_BODY type requests",
|
"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");
|
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(
|
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,
|
firstParty,
|
||||||
user,
|
user,
|
||||||
"Only first party can submit this step",
|
"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) 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)
|
firstParty.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -869,7 +948,7 @@ export class RequestManagementService {
|
|||||||
(firstParty?.person?.phoneNumber &&
|
(firstParty?.person?.phoneNumber &&
|
||||||
firstParty.person.phoneNumber === user?.username);
|
firstParty.person.phoneNumber === user?.username);
|
||||||
|
|
||||||
if (!isOwner) {
|
if (!isOwner && !this.isBlameOnBehalfActor(req, user)) {
|
||||||
throw new ForbiddenException("Only first party can upload this video");
|
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 {
|
try {
|
||||||
const req = await this.blameRequestDbService.findById(requestId);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) {
|
if (!req) {
|
||||||
@@ -977,11 +1061,22 @@ export class RequestManagementService {
|
|||||||
party,
|
party,
|
||||||
user,
|
user,
|
||||||
`Only ${role} party can submit this step`,
|
`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)
|
// Set userId for party if not already set (important for second party)
|
||||||
if (!party.person) party.person = {} as any;
|
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)
|
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: 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);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Request not found");
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
if (!req.workflow?.currentStep) {
|
if (!req.workflow?.currentStep) {
|
||||||
@@ -1359,11 +1459,22 @@ export class RequestManagementService {
|
|||||||
party,
|
party,
|
||||||
user,
|
user,
|
||||||
"Only the related party can submit location",
|
"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)
|
// Set userId for party if not already set (important for second party)
|
||||||
if (!party.person) party.person = {} as any;
|
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)
|
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1399,6 +1510,7 @@ export class RequestManagementService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
voice: Express.Multer.File,
|
voice: Express.Multer.File,
|
||||||
user: any,
|
user: any,
|
||||||
|
expectedRole?: string,
|
||||||
) {
|
) {
|
||||||
if (!voice) throw new BadRequestException("File is required");
|
if (!voice) throw new BadRequestException("File is required");
|
||||||
|
|
||||||
@@ -1427,11 +1539,22 @@ export class RequestManagementService {
|
|||||||
party,
|
party,
|
||||||
user,
|
user,
|
||||||
"Only the related party can upload voice",
|
"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)
|
// Set userId for party if not already set (important for second party)
|
||||||
if (!party.person) party.person = {} as any;
|
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)
|
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: 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);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Request not found");
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
if (!req.workflow?.currentStep) {
|
if (!req.workflow?.currentStep) {
|
||||||
@@ -1503,11 +1631,22 @@ export class RequestManagementService {
|
|||||||
party,
|
party,
|
||||||
user,
|
user,
|
||||||
"Only the related party can submit description",
|
"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)
|
// Set userId for party if not already set (important for second party)
|
||||||
if (!party.person) party.person = {} as any;
|
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)
|
party.person.userId = Types.ObjectId.isValid(user.sub)
|
||||||
? new Types.ObjectId(user.sub)
|
? new Types.ObjectId(user.sub)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1587,7 +1726,8 @@ export class RequestManagementService {
|
|||||||
isSecondThirdParty &&
|
isSecondThirdParty &&
|
||||||
closedByMutualAgreement &&
|
closedByMutualAgreement &&
|
||||||
req.status === CaseStatus.WAITING_FOR_SIGNATURES &&
|
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 firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||||
const secondIdx = this.getPartyIndex(req, PartyRole.SECOND);
|
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) {
|
if (stepKey === WorkflowStep.SECOND_DESCRIPTION) {
|
||||||
req.status = CaseStatus.WAITING_FOR_EXPERT;
|
req.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||||
}
|
}
|
||||||
@@ -1711,6 +1889,7 @@ export class RequestManagementService {
|
|||||||
firstParty,
|
firstParty,
|
||||||
user,
|
user,
|
||||||
"Only first party can invite second party",
|
"Only first party can invite second party",
|
||||||
|
req,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate second party phone number is different
|
// 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) {
|
// async createRequest(user, type) {
|
||||||
// const reqNumber = new ShortUniqueId({ counter: 1 });
|
// const reqNumber = new ShortUniqueId({ counter: 1 });
|
||||||
// const publicId = await this.publicIdService.generateRequestPublicId();
|
// const publicId = await this.publicIdService.generateRequestPublicId();
|
||||||
@@ -4036,30 +4322,6 @@ export class RequestManagementService {
|
|||||||
? BlameRequestType.CAR_BODY
|
? BlameRequestType.CAR_BODY
|
||||||
: BlameRequestType.THIRD_PARTY;
|
: 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 });
|
const firstStep = await this.getWorkflowStep({ stepNumber: 1 });
|
||||||
if (!firstStep?.stepKey) {
|
if (!firstStep?.stepKey) {
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -4079,37 +4341,10 @@ export class RequestManagementService {
|
|||||||
|
|
||||||
const publicId = await this.publicIdService.generateRequestPublicId();
|
const publicId = await this.publicIdService.generateRequestPublicId();
|
||||||
|
|
||||||
const parties: any[] = [];
|
// Always start with an empty first-party placeholder.
|
||||||
|
// For LINK files the phone + userId are stored when send-link is called.
|
||||||
if (dto.creationMethod === CreationMethod.LINK) {
|
// For IN_PERSON files the phone + userId are stored when verify-party-otp is called.
|
||||||
const firstUserId = await this.getOrCreateUserByPhoneNumber(
|
const parties: any[] = [{ role: PartyRole.FIRST, person: {} }];
|
||||||
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: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await this.blameRequestDbService.create({
|
const created = await this.blameRequestDbService.create({
|
||||||
publicId,
|
publicId,
|
||||||
@@ -4170,63 +4405,49 @@ export class RequestManagementService {
|
|||||||
async sendLinkV2(
|
async sendLinkV2(
|
||||||
expert: any,
|
expert: any,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
dto: { frontendRoute: string },
|
dto: { phoneNumber: string },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
sent: boolean;
|
sent: boolean;
|
||||||
linkUrl: string;
|
sentTo: { role: string; phoneNumber: string; smsSent: boolean; linkUrl: string }[];
|
||||||
sentTo: { role: string; phoneNumber: string; smsSent: boolean }[];
|
|
||||||
}> {
|
}> {
|
||||||
const req = await this.blameRequestDbService.findById(requestId);
|
const req = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!req) throw new NotFoundException("Request not found");
|
if (!req) throw new NotFoundException("Request not found");
|
||||||
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
|
if (!req.expertInitiated || req.creationMethod !== CreationMethod.LINK) {
|
||||||
throw new BadRequestException(
|
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);
|
this.verifyExpertAccessForBlameV2(req, expert);
|
||||||
|
|
||||||
if (!dto?.frontendRoute) {
|
const phone = (dto?.phoneNumber || "").trim();
|
||||||
throw new BadRequestException("frontendRoute is required");
|
if (!phone) throw new BadRequestException("phoneNumber is required");
|
||||||
}
|
|
||||||
if (!process.env.URL) {
|
if (!process.env.URL) {
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"URL environment variable is not configured",
|
"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 }[] =
|
// Store / update the first party's phone and bind a user account
|
||||||
[];
|
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
|
||||||
const sendSms = async (
|
if (firstIdx === -1)
|
||||||
phone: string,
|
throw new BadRequestException("First party not found on request");
|
||||||
role: PartyRole,
|
const userId = await this.getOrCreateUserByPhoneNumber(phone);
|
||||||
): Promise<boolean> => {
|
if (!req.parties[firstIdx].person) req.parties[firstIdx].person = {} as any;
|
||||||
return this.smsOrchestrationService.sendFieldExpertLink({
|
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,
|
receptor: phone,
|
||||||
type: req.type,
|
type: req.type,
|
||||||
expertLastName: expertName,
|
expertLastName: expertName,
|
||||||
link: linkUrl,
|
link: firstLink,
|
||||||
});
|
});
|
||||||
};
|
sentTo.push({ role: PartyRole.FIRST, phoneNumber: phone, smsSent, linkUrl: firstLink });
|
||||||
|
|
||||||
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 (!Array.isArray((req as any).history)) (req as any).history = [];
|
if (!Array.isArray((req as any).history)) (req as any).history = [];
|
||||||
(req as any).history.push({
|
(req as any).history.push({
|
||||||
@@ -4236,18 +4457,12 @@ export class RequestManagementService {
|
|||||||
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
|
||||||
actorType: "field_expert",
|
actorType: "field_expert",
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: { sentTo, template: "yara-field-expert-link" },
|
||||||
linkUrl,
|
|
||||||
sentTo,
|
|
||||||
template: "yara-field-expert-link",
|
|
||||||
frontendRoute: dto.frontendRoute,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await (req as any).save();
|
await (req as any).save();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent),
|
sent: sentTo.length > 0 && sentTo.every((x) => x.smsSent),
|
||||||
linkUrl,
|
|
||||||
sentTo,
|
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).
|
* List all expert-initiated blame files for the current field expert (their panel).
|
||||||
* Only files where initiatedBy === current user are returned.
|
* 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) req.expert = {} as any;
|
||||||
if (!req.expert.decision) req.expert.decision = {} 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 = {
|
||||||
...(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: {
|
fields: {
|
||||||
accidentWay: fields.accidentWay,
|
accidentWay: fields.accidentWay,
|
||||||
accidentReason: fields.accidentReason,
|
accidentReason: fields.accidentReason,
|
||||||
accidentType: fields.accidentType,
|
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();
|
await (req as any).save();
|
||||||
|
|
||||||
return { requestId: req._id };
|
return { requestId: req._id, workflow: req.workflow, status: req.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user