forked from Yara724/api
Compare commits
37 Commits
2b1edd64c1
...
b008eda11b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b008eda11b | ||
| 1680fdc5b4 | |||
|
|
921f9719c7 | ||
|
|
a7849f915a | ||
|
|
19dc2a76f2 | ||
|
|
41f81a2f76 | ||
| 048398d653 | |||
|
|
79905345e5 | ||
|
|
0ed7cd7012 | ||
| 1e7b0d7d06 | |||
|
|
6426233350 | ||
| 3e5e9852ad | |||
|
|
3d6b9e130c | ||
|
|
ec07d42ced | ||
| 407f58f5ee | |||
|
|
c8274d8435 | ||
| f0b24dcd26 | |||
|
|
5414d9717e | ||
|
|
e413991e7c | ||
| b144458943 | |||
|
|
3abbd45fac | ||
| cb47069e90 | |||
| 3c3b5191fb | |||
| 8053e1a088 | |||
|
|
d276c32e87 | ||
| 951ff9b50b | |||
|
|
a59e4c57a5 | ||
| 33c9811f61 | |||
|
|
cab584410f | ||
| c413bd3417 | |||
|
|
249c359898 | ||
| 393d43c4d2 | |||
|
|
34142942e5 | ||
|
|
f023d0f3e7 | ||
| 2d7afba75d | |||
|
|
9ee933cb76 | ||
|
|
16c598118d |
@@ -3,10 +3,13 @@
|
|||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
NODE_ENV =
|
NODE_ENV =
|
||||||
PORT =
|
PORT =
|
||||||
|
CLIENT_ID =
|
||||||
|
CLIENT_NAME =
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# 🌐 Application URLs
|
# 🌐 Application URLs
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
URL =
|
URL =
|
||||||
|
USER_BASE_PATH =
|
||||||
BASE_URL_DEV =
|
BASE_URL_DEV =
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
@@ -26,11 +29,13 @@ MONGO_DB_NAME =
|
|||||||
MONGO_OPTIONS =
|
MONGO_OPTIONS =
|
||||||
MONGO_TLS =
|
MONGO_TLS =
|
||||||
MONGO_TLS_ALLOW_INVALID_CERTS =
|
MONGO_TLS_ALLOW_INVALID_CERTS =
|
||||||
|
MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB_NAME}?${MONGO_OPTIONS}'
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# 🔐 Authentication / Security
|
# 🔐 Authentication / Security
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
JWT_EXPIRY =
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# 🧩 SanHub Microservice
|
# 🧩 SanHub Microservice
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -1 +1,15 @@
|
|||||||
# api
|
# api
|
||||||
|
|
||||||
|
Current JWT payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "saman_insurer@gmail.com",
|
||||||
|
"sub": "6a144979799f3c63aa63f67c",
|
||||||
|
"fullName": "بیمه گر سامان",
|
||||||
|
"role": "company",
|
||||||
|
"userType": "UserType",
|
||||||
|
"clientKey": "67f0fd0e53868dc1ff8a2738",
|
||||||
|
"iat": 1781339791,
|
||||||
|
"exp": 1781343391
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||||
import { Module, ValidationPipe } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||||
@@ -23,79 +23,19 @@ import { UsersModule } from "./users/users.module";
|
|||||||
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
||||||
import { CronModule } from "./utils/cron/cron.module";
|
import { CronModule } from "./utils/cron/cron.module";
|
||||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||||
import * as Joi from "joi";
|
import { ExpertInitiatedModule } from "./expert-initiated/expert-initiated.module";
|
||||||
|
import { DatabaseModule } from "./core/database/database.module";
|
||||||
|
import { AppConfigModule } from "./core/config/config.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
AppConfigModule,
|
||||||
isGlobal: true,
|
DatabaseModule,
|
||||||
validationSchema: Joi.object({
|
|
||||||
NODE_ENV: Joi.string()
|
|
||||||
.valid("development", "production")
|
|
||||||
.default("development"),
|
|
||||||
PORT: Joi.number().port().default(9001),
|
|
||||||
URL: Joi.string().uri().required(),
|
|
||||||
BASE_URL_DEV: Joi.string()
|
|
||||||
.uri()
|
|
||||||
.when("NODE_ENV", {
|
|
||||||
is: Joi.string().valid("development"),
|
|
||||||
then: Joi.required(),
|
|
||||||
otherwise: Joi.optional(),
|
|
||||||
}),
|
|
||||||
SWAGGER_USER_DEV: Joi.string().when("NODE_ENV", {
|
|
||||||
is: Joi.string().valid("development"),
|
|
||||||
then: Joi.required(),
|
|
||||||
otherwise: Joi.optional(),
|
|
||||||
}),
|
|
||||||
SWAGGER_PASSWORD_DEV: Joi.string().when("NODE_ENV", {
|
|
||||||
is: Joi.string().valid("development"),
|
|
||||||
then: Joi.required(),
|
|
||||||
otherwise: Joi.optional(),
|
|
||||||
}),
|
|
||||||
MONGO_HOST: Joi.string().required(),
|
|
||||||
MONGO_PORT: Joi.number().port().required(),
|
|
||||||
MONGO_USER: Joi.string().required(),
|
|
||||||
MONGO_PASS: Joi.string().required(),
|
|
||||||
MONGO_DB_NAME: Joi.string().required(),
|
|
||||||
JWT_SECRET: Joi.string().required(),
|
|
||||||
SANHUB_BASE_URL: Joi.string().uri(),
|
|
||||||
SANHUB_URL_LOGIN: Joi.string().uri(),
|
|
||||||
SANHUB_USERNAME: Joi.string(),
|
|
||||||
SANHUB_PASSWORD: Joi.string(),
|
|
||||||
AI_URL: Joi.string().uri(),
|
|
||||||
AI_URL_V2: Joi.string().uri(),
|
|
||||||
AI_USERNAME: Joi.string(),
|
|
||||||
AI_PASSWORD: Joi.string(),
|
|
||||||
SMS_PROVIDER: Joi.string()
|
|
||||||
.valid("kavenegar", "parsian")
|
|
||||||
.default("kavenegar"),
|
|
||||||
SMS_API_KEY: Joi.string(),
|
|
||||||
AUTH_SMS_TEMPLATE: Joi.string(),
|
|
||||||
EXP_OTP_TIME: Joi.number(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
CronModule,
|
CronModule,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, "..", "files"),
|
rootPath: join(__dirname, "..", "files"),
|
||||||
serveRoot: "/files",
|
serveRoot: "/files",
|
||||||
}),
|
}),
|
||||||
MongooseModule.forRootAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (configService: ConfigService) => {
|
|
||||||
return {
|
|
||||||
uri: `mongodb://${configService.get<string>("MONGO_USER")}:${configService.get<string>("MONGO_PASS")}@${configService.get<string>("MONGO_HOST")}:${configService.get<string>("MONGO_PORT")}/${configService.get<string>("MONGO_DB_NAME")}?authSource=admin&${configService.get<string>("MONGO_OPTIONS")}`,
|
|
||||||
tls: configService.get<string>("MONGO_TLS") === "true",
|
|
||||||
tlsAllowInvalidCertificates:
|
|
||||||
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") ===
|
|
||||||
"true",
|
|
||||||
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
|
||||||
connectionFactory: (connection) => {
|
|
||||||
applyIranFaTimestampPlugin(connection);
|
|
||||||
return connection;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
@@ -112,6 +52,7 @@ import * as Joi from "joi";
|
|||||||
ExpertInsurerModule,
|
ExpertInsurerModule,
|
||||||
LookupsModule,
|
LookupsModule,
|
||||||
WorkflowStepManagementModule,
|
WorkflowStepManagementModule,
|
||||||
|
// ExpertInitiatedModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export class UserAuthService {
|
|||||||
|
|
||||||
if (!userExist) {
|
if (!userExist) {
|
||||||
await this.smsSender(otp, canonicalMobile);
|
await this.smsSender(otp, canonicalMobile);
|
||||||
|
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||||
const newUser = await this.userDbService.createUser({
|
const newUser = await this.userDbService.createUser({
|
||||||
mobile: canonicalMobile,
|
mobile: canonicalMobile,
|
||||||
username: canonicalMobile,
|
username: canonicalMobile,
|
||||||
@@ -144,6 +145,7 @@ export class UserAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.smsSender(otp, canonicalMobile);
|
await this.smsSender(otp, canonicalMobile);
|
||||||
|
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||||
await this.userDbService.findOneAndUpdate(
|
await this.userDbService.findOneAndUpdate(
|
||||||
buildUserLookupByPhone(canonicalMobile),
|
buildUserLookupByPhone(canonicalMobile),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export class CaptchaService {
|
|||||||
generate(): GeneratedCaptcha {
|
generate(): GeneratedCaptcha {
|
||||||
const captcha = svgCaptcha.create({
|
const captcha = svgCaptcha.create({
|
||||||
size: 5,
|
size: 5,
|
||||||
ignoreChars: "0oO1ilI",
|
ignoreChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||||
noise: 3,
|
noise: 1,
|
||||||
color: true,
|
color: false,
|
||||||
background: "#f8fafc",
|
background: "#f8fafc",
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 56,
|
height: 56,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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 { RegistrarClaimMirrorController } from "./registrar-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 +99,8 @@ import { HttpModule } from "@nestjs/axios";
|
|||||||
ClaimRequestManagementController,
|
ClaimRequestManagementController,
|
||||||
ClaimRequestManagementV2Controller,
|
ClaimRequestManagementV2Controller,
|
||||||
RegistrarClaimV1Controller,
|
RegistrarClaimV1Controller,
|
||||||
|
ExpertInitiatedClaimMirrorController,
|
||||||
|
RegistrarClaimMirrorController,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ClaimRequestManagementService,
|
ClaimRequestManagementService,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { isAxiosError } from "axios";
|
|||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { AiService } from "src/ai/ai.service";
|
import { AiService } from "src/ai/ai.service";
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||||||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
||||||
@@ -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 ?? {},
|
||||||
@@ -5156,7 +5157,6 @@ export class ClaimRequestManagementService {
|
|||||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claimCase) {
|
if (!claimCase) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`Claim case with ID ${claimRequestId} not found`,
|
`Claim case with ID ${claimRequestId} not found`,
|
||||||
@@ -5214,6 +5214,7 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResendUpload) {
|
if (isResendUpload) {
|
||||||
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
if (claimCase.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -5262,7 +5263,6 @@ export class ClaimRequestManagementService {
|
|||||||
|
|
||||||
const fileUrl = buildFileLink(file.path);
|
const fileUrl = buildFileLink(file.path);
|
||||||
|
|
||||||
// Create document reference in claim-required-documents collection
|
|
||||||
const docRef = await this.claimRequiredDocumentDbService.create({
|
const docRef = await this.claimRequiredDocumentDbService.create({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
fileName: file.filename,
|
fileName: file.filename,
|
||||||
@@ -5271,8 +5271,24 @@ export class ClaimRequestManagementService {
|
|||||||
uploadedAt: new Date(),
|
uploadedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update claim case
|
// Base history entry for this upload
|
||||||
const updateData: any = {
|
const uploadHistoryEntry = {
|
||||||
|
type: "DOCUMENT_UPLOADED",
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
|
actorName: claimCase.owner?.fullName || "User",
|
||||||
|
actorType: "user",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
documentKey: body.documentKey,
|
||||||
|
fileName: file.filename,
|
||||||
|
fileId: docRef._id.toString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base $set — always applied
|
||||||
|
const $set: Record<string, unknown> = {
|
||||||
[`requiredDocuments.${body.documentKey}`]: {
|
[`requiredDocuments.${body.documentKey}`]: {
|
||||||
fileId: docRef._id,
|
fileId: docRef._id,
|
||||||
filePath: file.path,
|
filePath: file.path,
|
||||||
@@ -5280,33 +5296,16 @@ export class ClaimRequestManagementService {
|
|||||||
uploaded: true,
|
uploaded: true,
|
||||||
uploadedAt: new Date(),
|
uploadedAt: new Date(),
|
||||||
},
|
},
|
||||||
$push: {
|
|
||||||
history: {
|
|
||||||
type: "DOCUMENT_UPLOADED",
|
|
||||||
actor: {
|
|
||||||
actorId: new Types.ObjectId(currentUserId),
|
|
||||||
actorName: claimCase.owner?.fullName || "User",
|
|
||||||
actorType: "user",
|
|
||||||
},
|
|
||||||
timestamp: new Date(),
|
|
||||||
metadata: {
|
|
||||||
documentKey: body.documentKey,
|
|
||||||
fileName: file.filename,
|
|
||||||
fileId: docRef._id.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// History entries to push — always at least the upload entry
|
||||||
|
const historyEntries: unknown[] = [uploadHistoryEntry];
|
||||||
|
|
||||||
|
// completedSteps entries to push
|
||||||
|
const completedStepsEntries: string[] = [];
|
||||||
|
|
||||||
let allDocumentsUploaded = false;
|
let allDocumentsUploaded = false;
|
||||||
let remaining = 0;
|
let remaining = 0;
|
||||||
|
|
||||||
// Will be true when this upload also completes capture-phase docs AND
|
|
||||||
// every angle + every selected damaged part has already been captured.
|
|
||||||
// In that case we advance the workflow exactly like `capturePartV2` does
|
|
||||||
// when the user finishes the last capture — otherwise the user would be
|
|
||||||
// stuck in CAPTURE_PART_DAMAGES with all captures done but no more
|
|
||||||
// captures to upload to trigger the transition.
|
|
||||||
let captureStepCompletedOnThisUpload = false;
|
let captureStepCompletedOnThisUpload = false;
|
||||||
|
|
||||||
if (!isResendUpload) {
|
if (!isResendUpload) {
|
||||||
@@ -5317,7 +5316,6 @@ export class ClaimRequestManagementService {
|
|||||||
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||||
(k) => !afterThis(k),
|
(k) => !afterThis(k),
|
||||||
).length;
|
).length;
|
||||||
allDocumentsUploaded = false;
|
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
const progressAfterDoc = getClaimCaptureProgress(claimCase, {
|
||||||
@@ -5330,31 +5328,29 @@ export class ClaimRequestManagementService {
|
|||||||
progressAfterDoc.capturePhaseDocsComplete
|
progressAfterDoc.capturePhaseDocsComplete
|
||||||
) {
|
) {
|
||||||
captureStepCompletedOnThisUpload = true;
|
captureStepCompletedOnThisUpload = true;
|
||||||
updateData["status"] =
|
$set["status"] = ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
||||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS;
|
$set["workflow.currentStep"] =
|
||||||
updateData["workflow.currentStep"] =
|
|
||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
||||||
updateData["workflow.nextStep"] =
|
$set["workflow.nextStep"] =
|
||||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||||
updateData.$push.history = [
|
|
||||||
updateData.$push.history,
|
completedStepsEntries.push(
|
||||||
{
|
ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
type: "STEP_COMPLETED",
|
);
|
||||||
actor: {
|
historyEntries.push({
|
||||||
actorId: new Types.ObjectId(currentUserId),
|
type: "STEP_COMPLETED",
|
||||||
actorName: claimCase.owner?.fullName || "User",
|
actor: {
|
||||||
actorType: "user",
|
actorId: new Types.ObjectId(currentUserId),
|
||||||
},
|
actorName: claimCase.owner?.fullName || "User",
|
||||||
timestamp: new Date(),
|
actorType: "user",
|
||||||
metadata: {
|
|
||||||
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
|
||||||
description:
|
|
||||||
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
timestamp: new Date(),
|
||||||
updateData.$push["workflow.completedSteps"] =
|
metadata: {
|
||||||
ClaimWorkflowStep.CAPTURE_PART_DAMAGES;
|
stepKey: ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
|
||||||
|
description:
|
||||||
|
"Angles, damaged parts, and capture-phase vehicle evidence (chassis, engine, metal plate) are complete. Please upload remaining required documents.",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -5370,14 +5366,17 @@ export class ClaimRequestManagementService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (allDocumentsUploaded) {
|
if (allDocumentsUploaded) {
|
||||||
updateData["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
$set["status"] = ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
updateData["claimStatus"] = ClaimStatus.PENDING;
|
$set["claimStatus"] = ClaimStatus.PENDING;
|
||||||
updateData["workflow.currentStep"] =
|
$set["workflow.currentStep"] =
|
||||||
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
ClaimWorkflowStep.USER_SUBMISSION_COMPLETE;
|
||||||
updateData["workflow.nextStep"] =
|
$set["workflow.nextStep"] =
|
||||||
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||||||
updateData.$push.history = [
|
|
||||||
updateData.$push.history,
|
completedStepsEntries.push(
|
||||||
|
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS,
|
||||||
|
);
|
||||||
|
historyEntries.push(
|
||||||
{
|
{
|
||||||
type: "STEP_COMPLETED",
|
type: "STEP_COMPLETED",
|
||||||
actor: {
|
actor: {
|
||||||
@@ -5405,16 +5404,30 @@ export class ClaimRequestManagementService {
|
|||||||
"User submission complete. Claim ready for damage expert review.",
|
"User submission complete. Claim ready for damage expert review.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
updateData.$push["workflow.completedSteps"] =
|
|
||||||
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the final MongoDB update — all operators explicit and clean
|
||||||
|
const updatePayload: Record<string, unknown> = { $set };
|
||||||
|
|
||||||
|
const $push: Record<string, unknown> = {
|
||||||
|
history:
|
||||||
|
historyEntries.length === 1
|
||||||
|
? historyEntries[0]
|
||||||
|
: { $each: historyEntries },
|
||||||
|
};
|
||||||
|
if (completedStepsEntries.length === 1) {
|
||||||
|
$push["workflow.completedSteps"] = completedStepsEntries[0];
|
||||||
|
} else if (completedStepsEntries.length > 1) {
|
||||||
|
$push["workflow.completedSteps"] = { $each: completedStepsEntries };
|
||||||
|
}
|
||||||
|
updatePayload.$push = $push;
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(
|
await this.claimCaseDbService.findByIdAndUpdate(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
updateData,
|
updatePayload,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isResendUpload) {
|
if (isResendUpload) {
|
||||||
@@ -6523,6 +6536,28 @@ export class ClaimRequestManagementService {
|
|||||||
},
|
},
|
||||||
{ lean: true },
|
{ lean: true },
|
||||||
);
|
);
|
||||||
|
} else if (actor?.role === RoleEnum.REGISTRAR) {
|
||||||
|
const registrarBlameIds = await this.blameRequestDbService
|
||||||
|
.find(
|
||||||
|
{
|
||||||
|
registrarInitiated: true,
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
initiatedByRegistrarId: new Types.ObjectId(currentUserId),
|
||||||
|
},
|
||||||
|
{ select: "_id", lean: true },
|
||||||
|
)
|
||||||
|
.then((docs) => docs.map((d) => (d as any)._id));
|
||||||
|
claims = await this.claimCaseDbService.find(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
||||||
|
...(registrarBlameIds.length
|
||||||
|
? [{ blameRequestId: { $in: registrarBlameIds } }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ lean: true },
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
claims = await this.claimCaseDbService.find(
|
claims = await this.claimCaseDbService.find(
|
||||||
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
{ "owner.userId": new Types.ObjectId(currentUserId) },
|
||||||
@@ -6632,7 +6667,7 @@ export class ClaimRequestManagementService {
|
|||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
carAngles[k] = {
|
carAngles[k] = {
|
||||||
captured: !!cap,
|
captured: !!cap,
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
url: resolveStoredFileUrl(cap),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname } from "node:path";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
|
import {
|
||||||
|
OuterPartCatalogItemDto,
|
||||||
|
SelectOuterPartsV2Dto,
|
||||||
|
SelectOuterPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-outer-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
SelectOtherPartsV2Dto,
|
||||||
|
SelectOtherPartsV2ResponseDto,
|
||||||
|
} from "./dto/select-other-parts-v2.dto";
|
||||||
|
import {
|
||||||
|
UploadRequiredDocumentV2Dto,
|
||||||
|
UploadRequiredDocumentV2ResponseDto,
|
||||||
|
} from "./dto/upload-document-v2.dto";
|
||||||
|
import {
|
||||||
|
CapturePartV2Dto,
|
||||||
|
CapturePartV2ResponseDto,
|
||||||
|
} from "./dto/capture-part-v2.dto";
|
||||||
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar claim flow that mirrors the normal user claim API
|
||||||
|
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
|
||||||
|
* claim pages by only swapping the route prefix:
|
||||||
|
*
|
||||||
|
* `v2/claim-request-management/*` -> `v2/registrar/claim-request-management/*`
|
||||||
|
*
|
||||||
|
* The registrar fills the claim (parts, captures, documents) on behalf of the
|
||||||
|
* damaged party for a claim created from a registrar-initiated IN_PERSON blame.
|
||||||
|
* After submission the file follows the exact normal review lifecycle:
|
||||||
|
* the damage expert prices it, and the owner can later object/resend.
|
||||||
|
* The registrar's job ends here — no reviewing panel is needed.
|
||||||
|
*/
|
||||||
|
@ApiTags("registrar claim (mirror v2)")
|
||||||
|
@Controller("v2/registrar/claim-request-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.REGISTRAR)
|
||||||
|
export class RegistrarClaimMirrorController {
|
||||||
|
constructor(
|
||||||
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post("create-from-blame/:blameRequestId")
|
||||||
|
@ApiParam({ name: "blameRequestId" })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Create claim from registrar-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
|
||||||
|
})
|
||||||
|
async createFromBlame(
|
||||||
|
@Param("blameRequestId") blameRequestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||||
|
blameRequestId,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("requests")
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] List my (in-person) claims" })
|
||||||
|
async getMyClaims(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Query() query: ListQueryV2Dto,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.getMyClaimsV2(
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("outer-parts-catalog")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get outer parts catalog (V2)",
|
||||||
|
description:
|
||||||
|
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts catalog",
|
||||||
|
type: [OuterPartCatalogItemDto],
|
||||||
|
})
|
||||||
|
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
|
||||||
|
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("car-other-part")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get other (non-body) parts catalog",
|
||||||
|
description:
|
||||||
|
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Other parts catalog" })
|
||||||
|
async getCarOtherParts() {
|
||||||
|
const raw = await readFile(
|
||||||
|
`${process.cwd()}/src/static/car-part.json`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("branches/:insuranceId")
|
||||||
|
@ApiParam({
|
||||||
|
name: "insuranceId",
|
||||||
|
description: "Insurer client id (MongoDB ObjectId)",
|
||||||
|
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get insurer branches (V2)",
|
||||||
|
description:
|
||||||
|
"Returns branch list for a given insurer/client id so frontend can render branch options.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||||
|
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
|
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||||
|
insuranceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("request/:claimRequestId")
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] Get claim details" })
|
||||||
|
async getClaimDetails(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||||
|
claimRequestId,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-outer-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: SelectOuterPartsV2Dto,
|
||||||
|
description: "Selected vehicle type + selected outer part IDs from catalog",
|
||||||
|
examples: {
|
||||||
|
example1: {
|
||||||
|
summary: "Sedan - minor front damage",
|
||||||
|
value: { carType: "sedan", selectedPartIds: [19, 21, 16] },
|
||||||
|
},
|
||||||
|
example2: {
|
||||||
|
summary: "SUV - left side impact",
|
||||||
|
value: { carType: "suv", selectedPartIds: [102, 103, 104, 107] },
|
||||||
|
},
|
||||||
|
example3: {
|
||||||
|
summary: "Hatchback - rear-end collision",
|
||||||
|
value: { carType: "hatchback", selectedPartIds: [225, 226, 210] },
|
||||||
|
},
|
||||||
|
example4: {
|
||||||
|
summary: "Pickup - two sides + roof",
|
||||||
|
value: { carType: "pickup", selectedPartIds: [319, 312, 330] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Outer parts selected successfully",
|
||||||
|
type: SelectOuterPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Outer parts already selected" })
|
||||||
|
async selectOuterParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOuterPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("select-other-parts/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
|
**Purpose:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
|
**Valid Other Parts (Optional):**
|
||||||
|
- engine, suspension, brake_system, electrical
|
||||||
|
- radiator, transmission, exhaust
|
||||||
|
- headlight, taillight, mirror, glass
|
||||||
|
|
||||||
|
**After Success:**
|
||||||
|
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-required-document",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `other-parts-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
otherParts: {
|
||||||
|
oneOf: [
|
||||||
|
{ type: "array", items: { type: "string" } },
|
||||||
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
|
],
|
||||||
|
example: ["engine", "suspension"],
|
||||||
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Other parts and bank information saved successfully",
|
||||||
|
type: SelectOtherPartsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
@ApiResponse({ status: 409, description: "Bank information already submitted" })
|
||||||
|
async selectOtherParts(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("capture-requirements/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get Capture Requirements (V2)",
|
||||||
|
description: `
|
||||||
|
**Get list of what needs to be captured:**
|
||||||
|
- Required documents (10 remaining at the documents step for third-party)
|
||||||
|
- Car angles (4 items: front, back, left, right)
|
||||||
|
- Damaged parts (based on selected outer parts)
|
||||||
|
|
||||||
|
Returns status of each item (uploaded/captured or not).
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture requirements retrieved successfully",
|
||||||
|
type: GetCaptureRequirementsV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: "Access denied" })
|
||||||
|
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||||
|
async getCaptureRequirements(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||||
|
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
|
claimRequestId,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-document/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-documents",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Required Document (V2 - Step 5)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||||
|
|
||||||
|
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
|
- damaged_driving_license_front/back
|
||||||
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
|
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||||
|
|
||||||
|
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE.
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["documentKey", "file"],
|
||||||
|
properties: {
|
||||||
|
documentKey: {
|
||||||
|
type: "string",
|
||||||
|
enum: [
|
||||||
|
"damaged_driving_license_front",
|
||||||
|
"damaged_driving_license_back",
|
||||||
|
"damaged_chassis_number",
|
||||||
|
"damaged_engine_photo",
|
||||||
|
"damaged_car_card_front",
|
||||||
|
"damaged_car_card_back",
|
||||||
|
"damaged_metal_plate",
|
||||||
|
"guilty_driving_license_front",
|
||||||
|
"guilty_driving_license_back",
|
||||||
|
"guilty_car_card_front",
|
||||||
|
"guilty_car_card_back",
|
||||||
|
"guilty_metal_plate",
|
||||||
|
],
|
||||||
|
example: "damaged_driving_license_front",
|
||||||
|
},
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Document uploaded successfully",
|
||||||
|
type: UploadRequiredDocumentV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
@ApiResponse({ status: 409, description: "Document already uploaded" })
|
||||||
|
async uploadDocument(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: UploadRequiredDocumentV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("capture-part/:claimRequestId")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/claim-captures",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||||
|
callback(null, filename);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||||
|
description: `
|
||||||
|
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||||
|
|
||||||
|
**Capture types:**
|
||||||
|
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||||
|
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||||
|
|
||||||
|
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["captureType", "captureKey", "file"],
|
||||||
|
properties: {
|
||||||
|
captureType: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["angle", "part"],
|
||||||
|
example: "angle",
|
||||||
|
},
|
||||||
|
captureKey: {
|
||||||
|
type: "string",
|
||||||
|
example: "front",
|
||||||
|
description:
|
||||||
|
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||||
|
},
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Capture saved successfully",
|
||||||
|
type: CapturePartV2ResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||||
|
async capturePart(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: CapturePartV2Dto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
): Promise<CapturePartV2ResponseDto> {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||||
|
return this.claimRequestManagementService.capturePartV2(
|
||||||
|
claimRequestId,
|
||||||
|
body,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("car-capture/:claimRequestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/car-capture-videos/",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `claim-video-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiParam({
|
||||||
|
name: "claimRequestId",
|
||||||
|
description: "The claim case ID",
|
||||||
|
example: "507f1f77bcf86cd799439011",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["file"],
|
||||||
|
properties: { file: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
|
||||||
|
description:
|
||||||
|
"Upload a walk-around video of the damaged car during the capture phase.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||||
|
async carCapture(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@UploadedFile("file") file: Express.Multer.File,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||||
|
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||||
|
claimRequestId,
|
||||||
|
file,
|
||||||
|
registrar.sub,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
|
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
Body,
|
ApiBody,
|
||||||
Controller,
|
ApiOperation,
|
||||||
Get,
|
ApiResponse,
|
||||||
Post,
|
ApiTags,
|
||||||
UseGuards,
|
} from "@nestjs/swagger";
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
|
||||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
|
||||||
import { Roles } from "src/decorators/roles.decorator";
|
|
||||||
import { CurrentUser } from "src/decorators/user.decorator";
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
|
||||||
|
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
import { ClientDto } from "./dto/create-client.dto";
|
import { ClientDto } from "./dto/create-client.dto";
|
||||||
|
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
|
||||||
|
|
||||||
@Controller("client")
|
@Controller("client")
|
||||||
@ApiTags("client-management")
|
@ApiTags("client-management")
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(GlobalGuard, RolesGuard)
|
|
||||||
@Roles(RoleEnum.ADMIN)
|
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
constructor(private readonly clientService: ClientService) {}
|
constructor(
|
||||||
|
private readonly clientService: ClientService,
|
||||||
|
private readonly systemSettingsService: SystemSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async addClient(@Body() client: ClientDto) {
|
async addClient(@Body() client: ClientDto) {
|
||||||
@@ -36,4 +34,33 @@ export class ClientController {
|
|||||||
async getClientList(@CurrentUser() user) {
|
async getClientList(@CurrentUser() user) {
|
||||||
return await this.clientService.getClientList();
|
return await this.clientService.getClientList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
|
||||||
|
@Patch("external-inquiries-live")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Enable or disable live external inquiries",
|
||||||
|
description:
|
||||||
|
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: SetExternalInquiriesLiveDto,
|
||||||
|
examples: {
|
||||||
|
enableLive: {
|
||||||
|
summary: "Enable live inquiries",
|
||||||
|
description: "Call real SandHub/Tejarat HTTP APIs.",
|
||||||
|
value: { enabled: true },
|
||||||
|
},
|
||||||
|
disableLive: {
|
||||||
|
summary: "Disable live inquiries (mock mode)",
|
||||||
|
description: "Use mocked inquiry responses; flows continue without external connectivity.",
|
||||||
|
value: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||||
|
async setExternalInquiriesLive(@Body() body?: SetExternalInquiriesLiveDto) {
|
||||||
|
return this.systemSettingsService.updateSettings({
|
||||||
|
externalApis: { sandHubUseLiveApi: body?.enabled === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { MongooseModule } from "@nestjs/mongoose";
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||||
import { ClientController } from "./client.controller";
|
import { ClientController } from "./client.controller";
|
||||||
import { ClientPanelController } from "./client-panel.controller";
|
import { ClientPanelController } from "./client-panel.controller";
|
||||||
import { ClientService } from "./client.service";
|
import { ClientService } from "./client.service";
|
||||||
@@ -10,6 +11,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SystemSettingsModule,
|
||||||
MongooseModule.forFeature([
|
MongooseModule.forFeature([
|
||||||
{ name: ClientModel.name, schema: ClientDbSchema },
|
{ name: ClientModel.name, schema: ClientDbSchema },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -142,6 +142,38 @@ export class ClientService {
|
|||||||
return await this.clientDbService.find({ clientCode: companyCode });
|
return await this.clientDbService.find({ clientCode: companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a client by insurer company code from an external inquiry.
|
||||||
|
* Creates the client when missing so inquiry flows don't fail on unknown codes.
|
||||||
|
*/
|
||||||
|
async findOrCreateClientByCompanyCode(
|
||||||
|
companyCode: number | string | null | undefined,
|
||||||
|
companyName: string | null | undefined,
|
||||||
|
) {
|
||||||
|
const name = typeof companyName === "string" ? companyName.trim() : "";
|
||||||
|
const code = Number(companyCode);
|
||||||
|
if (!name || !Number.isFinite(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = await this.clientDbService.find({ clientCode: code });
|
||||||
|
if (client) return client;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.addClient({
|
||||||
|
clientName: { persian: name, english: null },
|
||||||
|
clientCode: code,
|
||||||
|
useExpertMode: "legal",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client = await this.clientDbService.find({ clientCode: code });
|
||||||
|
if (client) return client;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clientDbService.find({ clientCode: code });
|
||||||
|
}
|
||||||
|
|
||||||
async getClients(): Promise<ClientDtoRs[]> {
|
async getClients(): Promise<ClientDtoRs[]> {
|
||||||
const clients = await this.clientDbService.findAll();
|
const clients = await this.clientDbService.findAll();
|
||||||
const show = clients.map((c) => new ClientDtoRs(c));
|
const show = clients.map((c) => new ClientDtoRs(c));
|
||||||
|
|||||||
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export class SetExternalInquiriesLiveDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"When true, SandHub/Tejarat live HTTP inquiries run. When false, mocked inquiry data is used.",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export class ClientModel {
|
|||||||
english: string;
|
english: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Prop({ required: false, unique: true, type: Object })
|
@Prop({ required: false, unique: false, type: Object })
|
||||||
property: {
|
property: {
|
||||||
smsApiKey: string;
|
smsApiKey: string;
|
||||||
};
|
};
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/common/auth/auth.module.ts
Normal file
32
src/common/auth/auth.module.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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";
|
||||||
|
import { HashService } from "./providers";
|
||||||
|
|
||||||
|
@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,
|
||||||
|
HashService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/common/auth/constants/hash.constants.ts
Normal file
8
src/common/auth/constants/hash.constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ScryptOptions } from "node:crypto";
|
||||||
|
|
||||||
|
export const KEY_LENGTH = 64;
|
||||||
|
export const SCRYPT_PARAMS: ScryptOptions = {
|
||||||
|
N: 19456, // CPU/memory cost
|
||||||
|
r: 8, // block size
|
||||||
|
p: 1, // parallelization
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/common/auth/providers/hash.provider.ts
Normal file
68
src/common/auth/providers/hash.provider.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
BinaryLike,
|
||||||
|
randomBytes,
|
||||||
|
scrypt,
|
||||||
|
ScryptOptions,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { KEY_LENGTH, SCRYPT_PARAMS } from "../constants/hash.constants";
|
||||||
|
|
||||||
|
const scryptAsync = promisify<
|
||||||
|
BinaryLike,
|
||||||
|
BinaryLike,
|
||||||
|
number,
|
||||||
|
ScryptOptions,
|
||||||
|
Buffer
|
||||||
|
>(scrypt);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HashService {
|
||||||
|
private readonly pepper: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.pepper = this.configService.get<string>("HASH_PEPPER");
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPepper(input: string): string {
|
||||||
|
return this.pepper ? `${input}${this.pepper}` : input;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSalt(bytes: number = 16): string {
|
||||||
|
return randomBytes(bytes).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scrypt(password: string): Promise<[string, string]> {
|
||||||
|
const salt = this.generateSalt();
|
||||||
|
const pepperedInput = this.applyPepper(password);
|
||||||
|
const hashedPassword = await scryptAsync(
|
||||||
|
pepperedInput,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [salt, hashedPassword.toString("hex")];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyScrypt(
|
||||||
|
password: string,
|
||||||
|
salt: string,
|
||||||
|
hashedPassword: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pepperedInput = this.applyPepper(password);
|
||||||
|
const derived = await scryptAsync(
|
||||||
|
pepperedInput,
|
||||||
|
salt,
|
||||||
|
KEY_LENGTH,
|
||||||
|
SCRYPT_PARAMS,
|
||||||
|
);
|
||||||
|
const storedBuffer = Buffer.from(hashedPassword, "hex");
|
||||||
|
|
||||||
|
if (derived.length !== storedBuffer.length) return false;
|
||||||
|
|
||||||
|
return timingSafeEqual(derived, storedBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/common/auth/providers/index.ts
Normal file
1
src/common/auth/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { HashService } from "./hash.provider";
|
||||||
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;
|
||||||
|
}
|
||||||
14
src/core/config/config.module.ts
Normal file
14
src/core/config/config.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { validate } from "./config.validation";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
expandVariables: true,
|
||||||
|
validate,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
87
src/core/config/config.schema.ts
Normal file
87
src/core/config/config.schema.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
IsBooleanString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { StringValue } from "ms";
|
||||||
|
|
||||||
|
export enum Environment {
|
||||||
|
Development = "development",
|
||||||
|
Production = "production",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EnvironmentVariables {
|
||||||
|
@IsEnum(Environment, {
|
||||||
|
message: "NODE_ENV should be development or production",
|
||||||
|
})
|
||||||
|
NODE_ENV: Environment;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@IsNumber(
|
||||||
|
{
|
||||||
|
allowNaN: false,
|
||||||
|
allowInfinity: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "PORT must be a valid number",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@IsPositive({ message: "PORT must be positive" })
|
||||||
|
@Min(0, { message: "PORT must be between 0 and 65535" })
|
||||||
|
@Max(65535, { message: "PORT must be between 0 and 65535" })
|
||||||
|
PORT: number;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@IsString({ message: "MONGO_URI must be string" })
|
||||||
|
@IsUrl(
|
||||||
|
{
|
||||||
|
require_tld: false,
|
||||||
|
protocols: ["mongodb", "mongodb+srv"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "MONGO_URI must be in the correct format (mongodb://...)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
MONGO_URI: string;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@IsBooleanString({ message: "MONGO_TLS must be boolean" })
|
||||||
|
MONGO_TLS: string;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@IsBooleanString({ message: "MONGO_TLS_ALLOW_INVALID_CERTS must be boolean" })
|
||||||
|
MONGO_TLS_ALLOW_INVALID_CERTS: string;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@IsString({ message: "JWT_SECRET must be string" })
|
||||||
|
@MinLength(32, {
|
||||||
|
message: "JWT_SECRET must be at least 32 characters",
|
||||||
|
})
|
||||||
|
JWT_SECRET: string;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
|
||||||
|
@Matches(/^\d+(ms|s|m|h|d|w|y)$/, {
|
||||||
|
message: "JWT_EXPIRY must be in the correct format",
|
||||||
|
})
|
||||||
|
JWT_EXPIRY: StringValue;
|
||||||
|
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
@IsString({ message: "HASH_PEPPER must be string" })
|
||||||
|
@IsOptional()
|
||||||
|
HASH_PEPPER?: string;
|
||||||
|
// --------------------------------------------------------- //
|
||||||
|
}
|
||||||
23
src/core/config/config.validation.ts
Normal file
23
src/core/config/config.validation.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { validateSync } from "class-validator";
|
||||||
|
import { EnvironmentVariables } from "./config.schema";
|
||||||
|
|
||||||
|
export function validate(config: Record<string, unknown>) {
|
||||||
|
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||||
|
enableImplicitConversion: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = validateSync(validatedConfig, {
|
||||||
|
skipMissingProperties: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const messages = errors
|
||||||
|
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
throw new Error(`Environment validation failed:\n${messages}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validatedConfig;
|
||||||
|
}
|
||||||
19
src/core/database/database.module.ts
Normal file
19
src/core/database/database.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
uri: configService.get<string>("MONGO_URI"),
|
||||||
|
tls: configService.get<string>("MONGO_TLS") === "true",
|
||||||
|
tlsAllowInvalidCertificates:
|
||||||
|
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") === "true",
|
||||||
|
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// claim-damaged-part.enricher.ts
|
// claim-damaged-part.enricher.ts
|
||||||
|
|
||||||
|
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
import {
|
import {
|
||||||
DamagedPartCaptureStatus,
|
DamagedPartCaptureStatus,
|
||||||
EnrichedDamagedPart,
|
EnrichedDamagedPart,
|
||||||
@@ -14,6 +15,9 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
|
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
|
||||||
catalogLikeKeyFromPart: (part: any) => string;
|
catalogLikeKeyFromPart: (part: any) => string;
|
||||||
buildFileLink: (path: string) => string;
|
buildFileLink: (path: string) => string;
|
||||||
|
resolveStoredFileUrl?: (
|
||||||
|
stored?: { url?: string; path?: string } | null,
|
||||||
|
) => string | undefined;
|
||||||
}): EnrichedDamagedPart[] {
|
}): EnrichedDamagedPart[] {
|
||||||
const {
|
const {
|
||||||
selectedParts,
|
selectedParts,
|
||||||
@@ -24,29 +28,52 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
hasDamagedPartCapture,
|
hasDamagedPartCapture,
|
||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
|
resolveStoredFileUrl: resolveUrl = resolveStoredFileUrl,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
|
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
|
||||||
|
|
||||||
|
// objectionParts stores { partId, carPartDamage, side } — not { id, name }
|
||||||
const objectionParts: any[] =
|
const objectionParts: any[] =
|
||||||
evaluationBlock?.objection?.objectionParts ?? [];
|
evaluationBlock?.objection?.objectionParts ?? [];
|
||||||
|
|
||||||
|
// newParts stores { partId, partName, side } — not { id, name }
|
||||||
const newObjectionParts: any[] = evaluationBlock?.objection?.newParts ?? [];
|
const newObjectionParts: any[] = evaluationBlock?.objection?.newParts ?? [];
|
||||||
|
|
||||||
// Resend block — lives at evaluation.damageExpertResend
|
|
||||||
const damageExpertResend = evaluationBlock?.damageExpertResend;
|
const damageExpertResend = evaluationBlock?.damageExpertResend;
|
||||||
const resendCarParts: any[] = damageExpertResend?.resendCarParts ?? [];
|
const resendCarParts: any[] = damageExpertResend?.resendCarParts ?? [];
|
||||||
const resendFulfilledAt: Date | undefined = damageExpertResend?.fulfilledAt;
|
const resendFulfilledAt: Date | undefined = damageExpertResend?.fulfilledAt;
|
||||||
|
|
||||||
|
// Helpers that match against the ACTUAL stored field names
|
||||||
|
function partMatchesObjection(sp: any, objPart: any): boolean {
|
||||||
|
if (sp.id != null && objPart.partId != null && sp.id === objPart.partId)
|
||||||
|
return true;
|
||||||
|
const objName = objPart.carPartDamage ?? objPart.name ?? objPart.partName;
|
||||||
|
if (objName && (sp.name === objName || sp.label_fa === objName))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partMatchesNewObjection(sp: any, newPart: any): boolean {
|
||||||
|
if (sp.id != null && newPart.partId != null && sp.id === newPart.partId)
|
||||||
|
return true;
|
||||||
|
const newName = newPart.partName ?? newPart.name;
|
||||||
|
if (newName && (sp.name === newName || sp.label_fa === newName))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isPartInResend(sp: any): boolean {
|
function isPartInResend(sp: any): boolean {
|
||||||
return resendCarParts.some(
|
return resendCarParts.some(
|
||||||
(r: any) =>
|
(r: any) =>
|
||||||
r.id === sp.id || r.name === sp.name || r.catalogKey === sp.catalogKey,
|
(sp.id != null && r.id === sp.id) ||
|
||||||
|
r.catalogKey === sp.catalogKey ||
|
||||||
|
r.name === sp.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOrigin(sp: any): EnrichedDamagedPart["origin"] {
|
function resolveOrigin(sp: any): EnrichedDamagedPart["origin"] {
|
||||||
if (
|
if (newObjectionParts.some((p) => partMatchesNewObjection(sp, p))) {
|
||||||
newObjectionParts.some((p: any) => p.id === sp.id || p.name === sp.name)
|
|
||||||
) {
|
|
||||||
return "added_by_objection";
|
return "added_by_objection";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -65,7 +92,6 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
if (!captured) {
|
if (!captured) {
|
||||||
return inResend ? "resend_requested" : "pending";
|
return inResend ? "resend_requested" : "pending";
|
||||||
}
|
}
|
||||||
// captured=true + was in a resend request that is now fulfilled → "resent"
|
|
||||||
return inResend && resendFulfilledAt ? "resent" : "uploaded";
|
return inResend && resendFulfilledAt ? "resent" : "uploaded";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +103,26 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
ck,
|
ck,
|
||||||
selectedParts,
|
selectedParts,
|
||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
const captured = hasDamagedPartCapture(damagedPartsData, ck, selectedParts);
|
|
||||||
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
|
// Fallback: if id is null but media.damagedParts has a match by name
|
||||||
|
const captured =
|
||||||
|
hasDamagedPartCapture(damagedPartsData, ck, selectedParts) ||
|
||||||
|
(sp.id == null && Array.isArray(damagedPartsData)
|
||||||
|
? (damagedPartsData as any[]).some(
|
||||||
|
(d) => d.name === sp.name && d.side === sp.side && d.path,
|
||||||
|
)
|
||||||
|
: false);
|
||||||
|
|
||||||
|
const capUrl =
|
||||||
|
resolveUrl(cap) ||
|
||||||
|
(sp.id == null && Array.isArray(damagedPartsData)
|
||||||
|
? (() => {
|
||||||
|
const match = (damagedPartsData as any[]).find(
|
||||||
|
(d) => d.name === sp.name && d.side === sp.side && d.path,
|
||||||
|
);
|
||||||
|
return resolveUrl(match);
|
||||||
|
})()
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const matchingPriceDrop = priceDropLines.find(
|
const matchingPriceDrop = priceDropLines.find(
|
||||||
(line: any) =>
|
(line: any) =>
|
||||||
@@ -86,11 +130,9 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
|
line.priceDropPartKey?.toLowerCase() === sp.name?.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isObjected = objectionParts.some(
|
const isObjected = objectionParts.some((p) => partMatchesObjection(sp, p));
|
||||||
(p: any) => p.id === sp.id || p.name === sp.name,
|
const isNewlyAdded = newObjectionParts.some((p) =>
|
||||||
);
|
partMatchesNewObjection(sp, p),
|
||||||
const isNewlyAdded = newObjectionParts.some(
|
|
||||||
(p: any) => p.id === sp.id || p.name === sp.name,
|
|
||||||
);
|
);
|
||||||
const isNewByExpert = expertAddedParts.some(
|
const isNewByExpert = expertAddedParts.some(
|
||||||
(p: any) => p.id === sp.id || p.name === sp.name,
|
(p: any) => p.id === sp.id || p.name === sp.name,
|
||||||
@@ -109,7 +151,7 @@ export function buildEnrichedDamagedParts(params: {
|
|||||||
origin: resolveOrigin(sp),
|
origin: resolveOrigin(sp),
|
||||||
captureStatus: resolveCaptureStatus(sp, captured),
|
captureStatus: resolveCaptureStatus(sp, captured),
|
||||||
captured,
|
captured,
|
||||||
url,
|
url: capUrl,
|
||||||
|
|
||||||
objection: {
|
objection: {
|
||||||
isObjected,
|
isObjected,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { ClaimSignDbService } from "src/claim-request-management/entites/db-serv
|
|||||||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
||||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
||||||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
||||||
@@ -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";
|
||||||
@@ -92,7 +95,10 @@ import {
|
|||||||
buildMutualAgreementExpertDecision,
|
buildMutualAgreementExpertDecision,
|
||||||
enrichBlamePartiesForAgreementView,
|
enrichBlamePartiesForAgreementView,
|
||||||
} from "src/helpers/blame-party-agreement-decision";
|
} from "src/helpers/blame-party-agreement-decision";
|
||||||
import { resendRequestHasPayload } from "src/helpers/claim-expert-resend";
|
import {
|
||||||
|
normalizeResendDocumentKeys,
|
||||||
|
resendRequestHasPayload,
|
||||||
|
} from "src/helpers/claim-expert-resend";
|
||||||
import {
|
import {
|
||||||
claimCaseStatusAfterExpertReplyV2,
|
claimCaseStatusAfterExpertReplyV2,
|
||||||
classifyV2ExpertPricingParts,
|
classifyV2ExpertPricingParts,
|
||||||
@@ -133,6 +139,7 @@ import { CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN } from "src/constants/repair-amount-li
|
|||||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||||
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
import { buildEnrichedDamagedParts } from "./dto/claim-damaged-part.enricher";
|
||||||
|
import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document-keys";
|
||||||
|
|
||||||
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
const CLAIM_V2_TOTAL_PAYMENT_CAP = CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
||||||
|
|
||||||
@@ -2144,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);
|
||||||
|
|
||||||
@@ -2385,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);
|
||||||
@@ -2395,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);
|
||||||
@@ -2688,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(
|
||||||
@@ -2846,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(
|
||||||
@@ -3214,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);
|
||||||
@@ -3286,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;
|
||||||
@@ -3489,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).
|
||||||
@@ -3664,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();
|
||||||
@@ -3824,7 +3948,9 @@ export class ExpertClaimService {
|
|||||||
actor: any,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
const actorId = actor.sub;
|
const actorId = actor.sub;
|
||||||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -3832,39 +3958,48 @@ 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;
|
||||||
|
|
||||||
|
const assignedForReviewById = String(
|
||||||
|
(claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||||
|
);
|
||||||
|
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
||||||
|
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
||||||
|
const isAssignedToMe =
|
||||||
|
!!assignedForReviewById && assignedForReviewById === actorId;
|
||||||
|
|
||||||
|
// Gate: must satisfy at least one bucket
|
||||||
if (
|
if (
|
||||||
!isDamageExpertPhase &&
|
!isDamageExpertPhase &&
|
||||||
!isFactorValidationPending &&
|
!isFactorValidationPending &&
|
||||||
!isResendPending
|
!isResendPending &&
|
||||||
|
!isAssignedToMe
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership access control — same 4-bucket logic as list
|
|
||||||
const assignedForReviewById = String(
|
|
||||||
(claim.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
|
||||||
);
|
|
||||||
const lockEnforced = this.isClaimV2WorkflowLockCurrentlyEnforced(claim);
|
|
||||||
const lockedById = String(claim.workflow?.lockedBy?.actorId ?? "");
|
|
||||||
|
|
||||||
const isAvailable =
|
const isAvailable =
|
||||||
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
|
isDamageExpertPhase && !assignedForReviewById && !lockEnforced;
|
||||||
const isAssignedToMe =
|
|
||||||
!!assignedForReviewById && assignedForReviewById === actorId;
|
|
||||||
const isLockedByMe = lockEnforced && lockedById === actorId;
|
const isLockedByMe = lockEnforced && lockedById === actorId;
|
||||||
// Factor validation is open to all tenant experts (no individual ownership)
|
|
||||||
const isFactorValidationOpen = isFactorValidationPending;
|
const isFactorValidationOpen = isFactorValidationPending;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -3887,13 +4022,21 @@ 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)
|
||||||
const hasCapture = (data: any, key: string) =>
|
|
||||||
data && (data instanceof Map ? data.get(key) : data[key]);
|
|
||||||
|
|
||||||
// Build requiredDocuments map
|
// Build requiredDocuments map
|
||||||
const requiredDocs = claim.requiredDocuments as any;
|
const requiredDocs = claim.requiredDocuments as any;
|
||||||
const requiredDocumentsStatus: Record<string, any> = {};
|
const requiredDocumentsStatus: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Resend documents requested by expert — normalize to canonical keys for comparison
|
||||||
|
const resendDocumentKeys = new Set(
|
||||||
|
normalizeResendDocumentKeys(
|
||||||
|
(claim as any).evaluation?.damageExpertResend?.resendDocuments,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const resendFulfilled = !!(claim as any).evaluation?.damageExpertResend
|
||||||
|
?.fulfilledAt;
|
||||||
|
|
||||||
if (requiredDocs) {
|
if (requiredDocs) {
|
||||||
const keys =
|
const keys =
|
||||||
requiredDocs instanceof Map
|
requiredDocs instanceof Map
|
||||||
@@ -3902,11 +4045,18 @@ export class ExpertClaimService {
|
|||||||
for (const k of keys as string[]) {
|
for (const k of keys as string[]) {
|
||||||
const doc =
|
const doc =
|
||||||
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
requiredDocs instanceof Map ? requiredDocs.get(k) : requiredDocs[k];
|
||||||
|
const canonicalKey = canonicalizeResendDocumentKey(k) ?? k;
|
||||||
|
const wasResendRequested = resendDocumentKeys.has(canonicalKey);
|
||||||
|
|
||||||
requiredDocumentsStatus[k] = {
|
requiredDocumentsStatus[k] = {
|
||||||
uploaded: !!doc?.uploaded,
|
uploaded: !!doc?.uploaded,
|
||||||
fileId: doc?.fileId?.toString(),
|
fileId: doc?.fileId?.toString(),
|
||||||
fileName: doc?.fileName,
|
fileName: doc?.fileName,
|
||||||
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
|
filePath: doc?.filePath ? buildFileLink(doc.filePath) : undefined,
|
||||||
|
resend: {
|
||||||
|
wasRequested: wasResendRequested,
|
||||||
|
isFulfilled: wasResendRequested && resendFulfilled,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3923,11 +4073,11 @@ export class ExpertClaimService {
|
|||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
carAngles[k] = {
|
carAngles[k] = {
|
||||||
captured: !!cap,
|
captured: !!cap,
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
url: resolveStoredFileUrl(cap),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build damaged parts list (aligned with normalized selected parts and enriched with evaluation context)
|
// Build enriched damaged parts
|
||||||
const carTypeExpert = claim.vehicle?.carType as
|
const carTypeExpert = claim.vehicle?.carType as
|
||||||
| ClaimVehicleTypeV2
|
| ClaimVehicleTypeV2
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -3938,26 +4088,19 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
const damagedPartsDataExpert = claim.media?.damagedParts as any;
|
const damagedPartsDataExpert = claim.media?.damagedParts as any;
|
||||||
|
|
||||||
// Extract optimization lookups from the DB document safely
|
|
||||||
const evaluationBlock = (claim as any).evaluation;
|
|
||||||
const priceDropLines = evaluationBlock?.priceDrop?.partLines || [];
|
|
||||||
const objectionParts = evaluationBlock?.objection?.objectionParts || [];
|
|
||||||
const newObjectionParts = evaluationBlock?.objection?.newParts || [];
|
|
||||||
|
|
||||||
// Inside getClaimDetailV2, replace the damagedParts block:
|
|
||||||
|
|
||||||
const damagedParts = buildEnrichedDamagedParts({
|
const damagedParts = buildEnrichedDamagedParts({
|
||||||
selectedParts: selectedNormExpert,
|
selectedParts: selectedNormExpert,
|
||||||
damagedPartsData: damagedPartsDataExpert,
|
damagedPartsData: damagedPartsDataExpert,
|
||||||
evaluationBlock: (claim as any).evaluation, // damageExpertResend lives here already
|
evaluationBlock: (claim as any).evaluation,
|
||||||
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
expertAddedParts: (claim.damage as any)?.expertAddedParts ?? [],
|
||||||
getDamagedPartCaptureBlob,
|
getDamagedPartCaptureBlob,
|
||||||
hasDamagedPartCapture,
|
hasDamagedPartCapture,
|
||||||
catalogLikeKeyFromPart,
|
catalogLikeKeyFromPart,
|
||||||
buildFileLink,
|
buildFileLink,
|
||||||
|
resolveStoredFileUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Vehicle payload logic from "upstream"
|
// Vehicle payload — fall back to blame inquiry if claim vehicle is sparse
|
||||||
let vehiclePayload = claim.vehicle as any;
|
let vehiclePayload = claim.vehicle as any;
|
||||||
const hasClaimVehicle =
|
const hasClaimVehicle =
|
||||||
vehiclePayload &&
|
vehiclePayload &&
|
||||||
@@ -3968,7 +4111,6 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
let blameLean: any = null;
|
let blameLean: any = null;
|
||||||
|
|
||||||
// 2. Video capture and blame case db queries from "stashed"
|
|
||||||
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
|
const videoCaptureIdRaw = (claim.media as any)?.videoCaptureId;
|
||||||
const blameRequestIdStr = claim.blameRequestId?.toString();
|
const blameRequestIdStr = claim.blameRequestId?.toString();
|
||||||
|
|
||||||
@@ -3991,18 +4133,15 @@ export class ExpertClaimService {
|
|||||||
blameLean = blameLeanRows[0] ?? null;
|
blameLean = blameLeanRows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If claim vehicle not found and blameLean exists, replace vehiclePayload
|
|
||||||
if (!hasClaimVehicle && blameLean) {
|
if (!hasClaimVehicle && blameLean) {
|
||||||
const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim);
|
const fromBlame = this.damagedPartyVehicleFromBlame(blameLean, claim);
|
||||||
if (fromBlame) vehiclePayload = fromBlame;
|
if (fromBlame) vehiclePayload = fromBlame;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Blame file context logic from "upstream"
|
|
||||||
const blameFileContext = blameLean
|
const blameFileContext = blameLean
|
||||||
? this.blameFileContextForExpert(blameLean)
|
? this.blameFileContextForExpert(blameLean)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// 4. videoCapture logic from "stashed"
|
|
||||||
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
|
let videoCapture: ClaimDetailV2ResponseDto["videoCapture"] = undefined;
|
||||||
if (videoCaptureRow) {
|
if (videoCaptureRow) {
|
||||||
const vc = videoCaptureRow as any;
|
const vc = videoCaptureRow as any;
|
||||||
@@ -4036,28 +4175,12 @@ export class ExpertClaimService {
|
|||||||
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
? ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT
|
||||||
: claim.status;
|
: claim.status;
|
||||||
|
|
||||||
const objection = (claim as any).evaluation?.objection
|
|
||||||
? {
|
|
||||||
objectionParts:
|
|
||||||
(claim as any).evaluation.objection.objectionParts ?? [],
|
|
||||||
newParts: (claim as any).evaluation.objection.newParts ?? [],
|
|
||||||
submittedAt: (claim as any).evaluation.objection.submittedAt,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const blameCaseForApi = blameCase
|
const blameCaseForApi = blameCase
|
||||||
? this.sanitizeBlameCaseForClaimDetailApi(
|
? this.sanitizeBlameCaseForClaimDetailApi(
|
||||||
blameCase as Record<string, unknown>,
|
blameCase as Record<string, unknown>,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Enrich the full evaluation block with `signLink`s for every place a user
|
|
||||||
// signature is referenced, then assemble the response slice.
|
|
||||||
//
|
|
||||||
// We always expose owner approval blocks (when present in the DB) because
|
|
||||||
// the signature URL is needed across all phases of the claim. The expert
|
|
||||||
// reply payloads are still gated on `isFactorValidationPending` to match
|
|
||||||
// the historical contract of this endpoint.
|
|
||||||
const claimEvaluationRaw = (claim as any).evaluation as
|
const claimEvaluationRaw = (claim as any).evaluation as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -4122,7 +4245,6 @@ export class ExpertClaimService {
|
|||||||
blameRequestId: claim.blameRequestId?.toString(),
|
blameRequestId: claim.blameRequestId?.toString(),
|
||||||
blameRequestNo: claim.blameRequestNo,
|
blameRequestNo: claim.blameRequestNo,
|
||||||
money: moneyPayload,
|
money: moneyPayload,
|
||||||
selectedParts: selectedNormExpert,
|
|
||||||
otherParts: claim.damage?.otherParts,
|
otherParts: claim.damage?.otherParts,
|
||||||
requiredDocuments:
|
requiredDocuments:
|
||||||
Object.keys(requiredDocumentsStatus).length > 0
|
Object.keys(requiredDocumentsStatus).length > 0
|
||||||
@@ -4131,10 +4253,9 @@ export class ExpertClaimService {
|
|||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
awaitingFactorValidation: isFactorValidationPending,
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
evaluation: enrichedEvaluation as
|
evaluation: evaluationForApi as
|
||||||
| ClaimDetailV2ResponseDto["evaluation"]
|
| ClaimDetailV2ResponseDto["evaluation"]
|
||||||
| undefined,
|
| undefined,
|
||||||
objection,
|
|
||||||
videoCapture,
|
videoCapture,
|
||||||
blameCase: blameCaseForApi,
|
blameCase: blameCaseForApi,
|
||||||
blameExpertDecision,
|
blameExpertDecision,
|
||||||
@@ -4145,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}`,
|
||||||
@@ -4242,9 +4363,10 @@ export class ExpertClaimService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual path — change totalPriceDrop → total to match calculated path
|
||||||
const manualPayload = {
|
const manualPayload = {
|
||||||
manualOverride: true,
|
manualOverride: true,
|
||||||
totalPriceDrop: body.manualPriceDrop,
|
total: body.manualPriceDrop, // ← was totalPriceDrop
|
||||||
partLines: [],
|
partLines: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4269,6 +4391,18 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculated path — existing logic unchanged
|
// Calculated path — existing logic unchanged
|
||||||
|
// At the top of the calculated path, after the manualPriceDrop early return:
|
||||||
|
const existingPriceDrop = (claim as any).evaluation?.priceDrop;
|
||||||
|
if (
|
||||||
|
existingPriceDrop?.manualOverride === true &&
|
||||||
|
!body.partSeverities?.length
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This claim has a manual price-drop correction. " +
|
||||||
|
"To recalculate, provide partSeverities explicitly — this will replace the manual correction.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
const carType = claim.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||||
const selectedNorm = normalizeDamageSelectedParts(
|
const selectedNorm = normalizeDamageSelectedParts(
|
||||||
claim.damage?.selectedParts,
|
claim.damage?.selectedParts,
|
||||||
@@ -4362,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,
|
||||||
|
|||||||
32
src/expert-initiated/dtos/login.dto.ts
Normal file
32
src/expert-initiated/dtos/login.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class FieldExpertLoginDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsEmail({ allow_underscores: true })
|
||||||
|
@IsString({
|
||||||
|
message: "email is not string",
|
||||||
|
context: {
|
||||||
|
errorCode: 4000,
|
||||||
|
note: "The validated email type must be string",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
readonly email: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: "password is empty",
|
||||||
|
context: {
|
||||||
|
errorCode: 4001,
|
||||||
|
note: "The validated password must not be empty",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@IsString({
|
||||||
|
message: "password is not string",
|
||||||
|
context: {
|
||||||
|
errorCode: 4002,
|
||||||
|
note: "The validated password type must be string",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
readonly password: string;
|
||||||
|
}
|
||||||
16
src/expert-initiated/expert-initiated.controller.ts
Normal file
16
src/expert-initiated/expert-initiated.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||||
|
import { FieldExpertService } from "./expert-initiated.service";
|
||||||
|
|
||||||
|
@Controller("expert-initiated")
|
||||||
|
@ApiTags("Expert Initiated Flow (Field Expert)")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class FieldExpertController {
|
||||||
|
constructor(private readonly fieldExpertService: FieldExpertService) {}
|
||||||
|
|
||||||
|
@Post("login")
|
||||||
|
async login(@Body() fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||||
|
return await this.fieldExpertService.login(fieldExpertLoginDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/expert-initiated/expert-initiated.module.ts
Normal file
25
src/expert-initiated/expert-initiated.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { MongooseModule } from "@nestjs/mongoose";
|
||||||
|
import { FieldExpert, FieldExpertSchema } from "./schemas/field-expert.schema";
|
||||||
|
import { FieldExpertController } from "./expert-initiated.controller";
|
||||||
|
import { FieldExpertService } from "./expert-initiated.service";
|
||||||
|
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||||
|
import { AuthModule } from "src/auth/auth.module";
|
||||||
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{
|
||||||
|
name: FieldExpert.name,
|
||||||
|
schema: FieldExpertSchema,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
AuthModule,
|
||||||
|
JwtModule,
|
||||||
|
],
|
||||||
|
controllers: [FieldExpertController],
|
||||||
|
providers: [FieldExpertRepository, FieldExpertService],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class ExpertInitiatedModule {}
|
||||||
32
src/expert-initiated/expert-initiated.service.ts
Normal file
32
src/expert-initiated/expert-initiated.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FieldExpertRepository } from "./repositories/field-expert.repository";
|
||||||
|
import { FieldExpertLoginDto } from "./dtos/login.dto";
|
||||||
|
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldExpertService {
|
||||||
|
constructor(
|
||||||
|
private readonly fieldExpertRepository: FieldExpertRepository,
|
||||||
|
private readonly authService: UserAuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(fieldExpertLoginDto: FieldExpertLoginDto) {
|
||||||
|
const user = await this.fieldExpertRepository.retrieveByEmail(
|
||||||
|
fieldExpertLoginDto.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) throw new NotFoundException("User not found");
|
||||||
|
|
||||||
|
const validate = await this.authService.validateUser(
|
||||||
|
user.email,
|
||||||
|
user.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validate)
|
||||||
|
throw new BadRequestException("Username/Password does not match");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/expert-initiated/repositories/field-expert.repository.ts
Normal file
22
src/expert-initiated/repositories/field-expert.repository.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { Model } from "mongoose";
|
||||||
|
import { FieldExpert } from "../schemas/field-expert.schema";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldExpertRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(FieldExpert.name)
|
||||||
|
private readonly fieldExpertModel: Model<FieldExpert>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async retrieveById(id: string): Promise<FieldExpert> {
|
||||||
|
return await this.fieldExpertModel.findById(id).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveByEmail(email: string): Promise<FieldExpert> {
|
||||||
|
return await this.fieldExpertModel.findOne({
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/expert-initiated/schemas/field-expert.schema.ts
Normal file
27
src/expert-initiated/schemas/field-expert.schema.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { HydratedDocument, Types } from "mongoose";
|
||||||
|
|
||||||
|
export type FieldExpertDocument = HydratedDocument<FieldExpert>;
|
||||||
|
|
||||||
|
@Schema({
|
||||||
|
id: true,
|
||||||
|
timestamps: true,
|
||||||
|
})
|
||||||
|
export class FieldExpert {
|
||||||
|
@Prop({ unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
cellphone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldExpertSchema = SchemaFactory.createForClass(FieldExpert);
|
||||||
@@ -33,7 +33,7 @@ import { BlameVideoDbService } from "src/request-management/entities/db-service/
|
|||||||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
||||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
import { buildFileLink, resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
|
import { enrichBlamePartiesForAgreementView } from "src/helpers/blame-party-agreement-decision";
|
||||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
@@ -422,7 +422,7 @@ export class ExpertInsurerService {
|
|||||||
ck,
|
ck,
|
||||||
selectedNormExpert,
|
selectedNormExpert,
|
||||||
) as { url?: string; path?: string; fileName?: string } | undefined;
|
) as { url?: string; path?: string; fileName?: string } | undefined;
|
||||||
const url = cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined);
|
const url = resolveStoredFileUrl(cap);
|
||||||
const link = url;
|
const link = url;
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
@@ -633,7 +633,7 @@ export class ExpertInsurerService {
|
|||||||
) as { url?: string; path?: string } | undefined;
|
) as { url?: string; path?: string } | undefined;
|
||||||
carAngles[k] = {
|
carAngles[k] = {
|
||||||
captured: !!cap,
|
captured: !!cap,
|
||||||
url: cap?.url || (cap?.path ? buildFileLink(cap.path) : undefined),
|
url: resolveStoredFileUrl(cap),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,6 +1099,8 @@ export class ExpertInsurerService {
|
|||||||
|
|
||||||
async retrieveAllFilesOfClient(insurerId: string) {
|
async retrieveAllFilesOfClient(insurerId: string) {
|
||||||
const id = this.getClientId(insurerId);
|
const id = this.getClientId(insurerId);
|
||||||
|
const idStr = String(id);
|
||||||
|
|
||||||
const [blameFiles, claimFiles] = await Promise.all([
|
const [blameFiles, claimFiles] = await Promise.all([
|
||||||
this.getClientBlameFiles(id),
|
this.getClientBlameFiles(id),
|
||||||
this.getClientClaimFiles(id),
|
this.getClientClaimFiles(id),
|
||||||
@@ -1112,11 +1114,7 @@ export class ExpertInsurerService {
|
|||||||
parties?: Array<{
|
parties?: Array<{
|
||||||
role?: string;
|
role?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
vehicle?: {
|
vehicle?: { carName?: string; carModel?: string; plate?: string };
|
||||||
carName?: string;
|
|
||||||
carModel?: string;
|
|
||||||
plate?: string;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
blame?: {
|
blame?: {
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
@@ -1125,11 +1123,7 @@ export class ExpertInsurerService {
|
|||||||
parties?: Array<{
|
parties?: Array<{
|
||||||
role?: string;
|
role?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
vehicle?: {
|
vehicle?: { carName?: string; carModel?: string; plate?: string };
|
||||||
carName?: string;
|
|
||||||
carModel?: string;
|
|
||||||
plate?: string;
|
|
||||||
};
|
|
||||||
}>;
|
}>;
|
||||||
expertNames?: string[];
|
expertNames?: string[];
|
||||||
createdAt?: Date | string;
|
createdAt?: Date | string;
|
||||||
@@ -1150,6 +1144,7 @@ export class ExpertInsurerService {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
|
// Index blame files first
|
||||||
for (const b of blameFiles) {
|
for (const b of blameFiles) {
|
||||||
const key = String((b as any).publicId || (b as any)._id || "");
|
const key = String((b as any).publicId || (b as any)._id || "");
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
@@ -1163,7 +1158,7 @@ export class ExpertInsurerService {
|
|||||||
(b as any).requestNo || String((b as any).requestNumber || ""),
|
(b as any).requestNo || String((b as any).requestNumber || ""),
|
||||||
status: (b as any).status,
|
status: (b as any).status,
|
||||||
parties: partiesPreview,
|
parties: partiesPreview,
|
||||||
expertNames: extractExpertNamesFromBlame(b), // ← add
|
expertNames: extractExpertNamesFromBlame(b),
|
||||||
createdAt: (b as any).createdAt,
|
createdAt: (b as any).createdAt,
|
||||||
updatedAt: (b as any).updatedAt,
|
updatedAt: (b as any).updatedAt,
|
||||||
};
|
};
|
||||||
@@ -1173,10 +1168,61 @@ export class ExpertInsurerService {
|
|||||||
byPublicId.set(key, prev);
|
byPublicId.set(key, prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For claim files whose blame wasn't in blameFiles (different insurer's blame),
|
||||||
|
// fetch the linked blame directly so we can get vehicle + parties data
|
||||||
|
const claimOnlyBlameIds = claimFiles
|
||||||
|
.filter((c) => {
|
||||||
|
const key = String((c as any).publicId || (c as any)._id || "");
|
||||||
|
return key && !byPublicId.has(key) && (c as any).blameRequestId;
|
||||||
|
})
|
||||||
|
.map((c) => String((c as any).blameRequestId));
|
||||||
|
|
||||||
|
const uniqueBlameIds = [...new Set(claimOnlyBlameIds)].filter(Boolean);
|
||||||
|
|
||||||
|
const linkedBlames =
|
||||||
|
uniqueBlameIds.length > 0
|
||||||
|
? ((await this.blameRequestDbService.find(
|
||||||
|
{
|
||||||
|
_id: {
|
||||||
|
$in: uniqueBlameIds.map((id) => new Types.ObjectId(id)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ lean: true },
|
||||||
|
)) as any[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const linkedBlameByPublicId = new Map<string, any>(
|
||||||
|
linkedBlames.map((b) => [String(b.publicId), b]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index claim files
|
||||||
for (const c of claimFiles) {
|
for (const c of claimFiles) {
|
||||||
const key = String((c as any).publicId || (c as any)._id || "");
|
const key = String((c as any).publicId || (c as any)._id || "");
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const prev = byPublicId.get(key) ?? { publicId: key };
|
const prev = byPublicId.get(key) ?? { publicId: key };
|
||||||
|
|
||||||
|
// If no blame entry yet, use the linked blame for parties/vehicle/fileType
|
||||||
|
if (!prev.blame) {
|
||||||
|
const linkedBlame = linkedBlameByPublicId.get(key);
|
||||||
|
if (linkedBlame) {
|
||||||
|
const partiesFromBlame = this.buildPartiesPreview(
|
||||||
|
linkedBlame.parties,
|
||||||
|
);
|
||||||
|
prev.fileType = prev.fileType ?? linkedBlame.type;
|
||||||
|
prev.blame = {
|
||||||
|
requestId: String(linkedBlame._id),
|
||||||
|
requestNo: linkedBlame.requestNo || "",
|
||||||
|
status: linkedBlame.status,
|
||||||
|
parties: partiesFromBlame,
|
||||||
|
expertNames: extractExpertNamesFromBlame(linkedBlame),
|
||||||
|
createdAt: linkedBlame.createdAt,
|
||||||
|
updatedAt: linkedBlame.updatedAt,
|
||||||
|
};
|
||||||
|
prev.parties = prev.parties ?? partiesFromBlame;
|
||||||
|
prev.createdAt = prev.createdAt ?? linkedBlame.createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const claimPartiesPreview = this.buildPartiesPreview(
|
const claimPartiesPreview = this.buildPartiesPreview(
|
||||||
(c as any)?.snapshot?.parties,
|
(c as any)?.snapshot?.parties,
|
||||||
);
|
);
|
||||||
@@ -1189,7 +1235,7 @@ export class ExpertInsurerService {
|
|||||||
status: (c as any).status,
|
status: (c as any).status,
|
||||||
claimStatus: (c as any).claimStatus,
|
claimStatus: (c as any).claimStatus,
|
||||||
currentStep: (c as any).currentStep,
|
currentStep: (c as any).currentStep,
|
||||||
expertNames: extractExpertNamesFromClaim(c), // ← add
|
expertNames: extractExpertNamesFromClaim(c),
|
||||||
createdAt: (c as any).createdAt,
|
createdAt: (c as any).createdAt,
|
||||||
updatedAt: (c as any).updatedAt,
|
updatedAt: (c as any).updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
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 {}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "./outer-damage-parts";
|
} from "./outer-damage-parts";
|
||||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||||
|
import { resolveStoredFileUrl } from "./urlCreator";
|
||||||
|
|
||||||
export type ExpertResendCarPartV2 = {
|
export type ExpertResendCarPartV2 = {
|
||||||
id?: number | null;
|
id?: number | null;
|
||||||
@@ -161,8 +162,7 @@ export function mapExpertResendCarPartsForClient(
|
|||||||
const capturedFromStore =
|
const capturedFromStore =
|
||||||
typeof stored.captured === "boolean" ? stored.captured : undefined;
|
typeof stored.captured === "boolean" ? stored.captured : undefined;
|
||||||
const url =
|
const url =
|
||||||
cap?.url ||
|
resolveStoredFileUrl(cap);
|
||||||
(cap?.path && options.buildUrl ? options.buildUrl(cap.path) : undefined);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sp.id,
|
id: sp.id,
|
||||||
@@ -260,6 +260,19 @@ export function canFinalizeExpertResend(claim: any): boolean {
|
|||||||
claim?.damage?.selectedParts,
|
claim?.damage?.selectedParts,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("[canFinalizeExpertResend]", {
|
||||||
|
docKeys,
|
||||||
|
partKeys,
|
||||||
|
docResults: docKeys.map((k) => ({
|
||||||
|
k,
|
||||||
|
uploaded: isRequiredDocumentUploaded(claim, k),
|
||||||
|
})),
|
||||||
|
partResults: partKeys.map((k) => ({
|
||||||
|
k,
|
||||||
|
captured: hasDamagedPartCapture(claim, k),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
if (docKeys.length === 0 && partKeys.length === 0) {
|
if (docKeys.length === 0 && partKeys.length === 0) {
|
||||||
return String(r.resendDescription || "").trim() !== "";
|
return String(r.resendDescription || "").trim() !== "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ export function requireActorClientKey(actor: { clientKey?: string }): string {
|
|||||||
|
|
||||||
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
|
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
|
||||||
const id = String(clientKey);
|
const id = String(clientKey);
|
||||||
|
|
||||||
|
// CAR_BODY: single party, always the first party's clientId
|
||||||
|
if (doc?.type === "CAR_BODY") {
|
||||||
|
const firstParty = (doc?.parties ?? []).find(
|
||||||
|
(p: any) => p?.role === "FIRST",
|
||||||
|
);
|
||||||
|
return String(firstParty?.person?.clientId ?? "") === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// THIRD_PARTY: only the guilty party's insurer sees this file
|
||||||
|
const guiltyClientId = resolveGuiltyPartyClientId(doc);
|
||||||
|
if (guiltyClientId) {
|
||||||
|
return guiltyClientId === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when guilt cannot be resolved yet — show to any party's insurer
|
||||||
|
// This should be rare since guilt is always known upfront in your system
|
||||||
return (doc?.parties ?? []).some(
|
return (doc?.parties ?? []).some(
|
||||||
(p: any) => String(p?.person?.clientId ?? "") === id,
|
(p: any) => String(p?.person?.clientId ?? "") === id,
|
||||||
);
|
);
|
||||||
@@ -27,14 +44,11 @@ export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
|
|||||||
return String(tenantId ?? "") === id;
|
return String(tenantId ?? "") === id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Field expert–initiated cases may not yet have party clientId filled; the
|
|
||||||
* initiating expert is always scoped to their insurer via JWT.
|
|
||||||
*/
|
|
||||||
export function blameCaseAccessibleToExpert(
|
export function blameCaseAccessibleToExpert(
|
||||||
doc: any,
|
doc: any,
|
||||||
actor: { sub: string; clientKey?: string },
|
actor: { sub: string; clientKey?: string },
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// Expert-initiated cases: only the initiating expert sees them
|
||||||
if (
|
if (
|
||||||
doc.expertInitiated &&
|
doc.expertInitiated &&
|
||||||
doc.initiatedByFieldExpertId &&
|
doc.initiatedByFieldExpertId &&
|
||||||
@@ -42,8 +56,11 @@ export function blameCaseAccessibleToExpert(
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ck = actor?.clientKey;
|
const ck = actor?.clientKey;
|
||||||
if (!ck) return false;
|
if (!ck) return false;
|
||||||
|
|
||||||
|
// Uses the updated blameCaseTouchesClient — guilty party's clientId only
|
||||||
return blameCaseTouchesClient(doc, ck);
|
return blameCaseTouchesClient(doc, ck);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +86,63 @@ export function assertClaimCaseForTenant(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when a field expert (no clientKey) initiated this claim via an
|
||||||
|
* expert-initiated IN_PERSON blame file.
|
||||||
|
*/
|
||||||
|
export function claimCaseInitiatedByFieldExpert(
|
||||||
|
doc: any,
|
||||||
|
actor: { sub: string },
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!!doc.initiatedByFieldExpertId &&
|
||||||
|
String(doc.initiatedByFieldExpertId) === String(actor.sub)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization check that works for both damage experts (clientKey tenant
|
||||||
|
* scope) and field experts (initiatedByFieldExpertId ownership).
|
||||||
|
*/
|
||||||
|
export function assertClaimCaseForExpertActor(
|
||||||
|
doc: any,
|
||||||
|
actor: { sub: string; clientKey?: string },
|
||||||
|
): void {
|
||||||
|
if (claimCaseInitiatedByFieldExpert(doc, actor)) return;
|
||||||
|
assertClaimCaseForTenant(doc, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGuiltyPartyClientId(doc: any): string | null {
|
||||||
|
const parties: any[] = doc?.parties ?? [];
|
||||||
|
|
||||||
|
// Phase 1: expert decision exists — use it
|
||||||
|
const guiltyUserId = doc?.expert?.decision?.guiltyPartyId
|
||||||
|
? String(doc.expert.decision.guiltyPartyId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (guiltyUserId) {
|
||||||
|
const guiltyParty = parties.find(
|
||||||
|
(p) => String(p?.person?.userId ?? "") === guiltyUserId,
|
||||||
|
);
|
||||||
|
return String(guiltyParty?.person?.clientId ?? "") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: no expert decision yet — use party self-statements
|
||||||
|
// The party who admitsGuilt=true is the guilty one
|
||||||
|
const guiltyByStatement = parties.find(
|
||||||
|
(p) => p?.statement?.admitsGuilt === true,
|
||||||
|
);
|
||||||
|
if (guiltyByStatement) {
|
||||||
|
return String(guiltyByStatement?.person?.clientId ?? "") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: fallback — use blameStatus field if set
|
||||||
|
// AGREED means first party admitted guilt (convention in your system)
|
||||||
|
if (doc?.blameStatus === "AGREED") {
|
||||||
|
const firstParty = parties.find((p) => p?.role === "FIRST");
|
||||||
|
return String(firstParty?.person?.clientId ?? "") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,62 @@
|
|||||||
export function buildFileLink(path: string): string {
|
type StoredFileCapture = {
|
||||||
const baseUrl = process.env.URL;
|
url?: string;
|
||||||
if(process.env.NODE_ENV === 'local'){
|
path?: string;
|
||||||
return baseUrl + "/" + path;
|
};
|
||||||
}else{
|
|
||||||
return baseUrl + "/api/" + path;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce any stored file reference to the canonical relative storage path
|
||||||
|
* (e.g. `files/claim-captures/x.jpg`).
|
||||||
|
*
|
||||||
|
* Handles values that were persisted as absolute URLs on a different portal
|
||||||
|
* (e.g. `https://host/car-damage/user/api/files/...`) so we never leak a
|
||||||
|
* baked-in host/prefix into responses served from another origin.
|
||||||
|
*/
|
||||||
|
function toRelativeFilePath(value: string | null | undefined): string {
|
||||||
|
const s = String(value ?? "").trim();
|
||||||
|
if (!s) return "";
|
||||||
|
|
||||||
|
// Prefer the `files/...` segment wherever it appears in the string.
|
||||||
|
const match = s.match(/(?:^|\/)(files\/.+)$/i);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
// No recognizable file root: keep external URLs intact, otherwise strip
|
||||||
|
// any leading slashes so we can safely prefix the base URL.
|
||||||
|
if (/^https?:\/\//i.test(s)) return s;
|
||||||
|
return s.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a public file URL from a stored path (relative or absolute).
|
||||||
|
*
|
||||||
|
* The link is always rebuilt from the current `URL` env, so deployments with
|
||||||
|
* custom routing (different sub-paths per portal) stay correct even when the
|
||||||
|
* persisted value was created elsewhere.
|
||||||
|
*/
|
||||||
|
export function buildFileLink(path: string): string {
|
||||||
|
const relative = toRelativeFilePath(path);
|
||||||
|
if (!relative) return "";
|
||||||
|
|
||||||
|
// Already an absolute external URL with no recognizable file root.
|
||||||
|
if (/^https?:\/\//i.test(relative)) return relative;
|
||||||
|
|
||||||
|
const baseUrl = (process.env.URL ?? "").replace(/\/+$/, "");
|
||||||
|
if (process.env.NODE_ENV === "local") {
|
||||||
|
return `${baseUrl}/${relative}`;
|
||||||
|
}
|
||||||
|
return `${baseUrl}/api/${relative}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a file URL from persisted capture metadata.
|
||||||
|
*
|
||||||
|
* Prefer `path`, fall back to `url`, and always rebuild through
|
||||||
|
* {@link buildFileLink} so stale portal-prefixed values (e.g. `.../user/api/...`)
|
||||||
|
* are normalized to the current server origin.
|
||||||
|
*/
|
||||||
|
export function resolveStoredFileUrl(
|
||||||
|
stored?: StoredFileCapture | null,
|
||||||
|
): string | undefined {
|
||||||
|
const source = stored?.path?.trim() || stored?.url?.trim();
|
||||||
|
if (!source) return undefined;
|
||||||
|
return buildFileLink(source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,433 @@
|
|||||||
|
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("/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,
|
||||||
|
|||||||
418
src/request-management/registrar-blame.mirror.controller.ts
Normal file
418
src/request-management/registrar-blame.mirror.controller.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import { extname } from "node:path";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||||
|
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
|
import {
|
||||||
|
CarBodyFormDto,
|
||||||
|
DescriptionDto,
|
||||||
|
LocationDto,
|
||||||
|
} from "./dto/create-request-management.dto";
|
||||||
|
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
|
||||||
|
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
|
||||||
|
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||||
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrar blame flow that mirrors the normal user blame API
|
||||||
|
* (`v2/blame-request-management`) one-to-one, using only the IN_PERSON method.
|
||||||
|
* The frontend can reuse the same pages by only swapping the route prefix:
|
||||||
|
*
|
||||||
|
* `v2/blame-request-management/*` -> `v2/registrar/blame-request-management/*`
|
||||||
|
*
|
||||||
|
* The registrar fills every step on behalf of both parties using the exact same
|
||||||
|
* endpoints/bodies as a normal user. By contract the FIRST party registered is
|
||||||
|
* always the guilty party. There is no LINK flow and no reviewing phase for the
|
||||||
|
* registrar — the job is finished once the file is signed.
|
||||||
|
*
|
||||||
|
* Sequence: create -> send/verify OTP (first party) -> first party steps
|
||||||
|
* (car-body-form or initial-form, upload-video, add-detail-location,
|
||||||
|
* upload-voice, add-detail-description) -> add-second-party ->
|
||||||
|
* send/verify OTP (second party) -> second party steps ->
|
||||||
|
* add-accident-fields -> sign FIRST + sign SECOND -> COMPLETED.
|
||||||
|
*/
|
||||||
|
@ApiTags("registrar blame (mirror v2)")
|
||||||
|
@Controller("v2/registrar/blame-request-management")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.REGISTRAR)
|
||||||
|
export class RegistrarBlameMirrorController {
|
||||||
|
constructor(
|
||||||
|
private readonly requestManagementService: RequestManagementService,
|
||||||
|
private readonly mediaPolicyService: MediaPolicyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Create registrar-initiated blame file",
|
||||||
|
description:
|
||||||
|
"Always IN_PERSON. Creates a BlameRequest owned by the parties but filled by the registrar. FIRST party registered is always the guilty party.",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: CreateRegistrarInitiatedFileDto })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Body() dto: CreateRegistrarInitiatedFileDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.createRegistrarInitiatedBlame(
|
||||||
|
registrar,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] List my registrar-initiated blame files",
|
||||||
|
})
|
||||||
|
async list(@CurrentUser() registrar: any) {
|
||||||
|
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("send-party-otp/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: SendPartyOtpDto })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Send OTP to one party (sequential IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"Sends an OTP SMS to a single party's phone number. Flow: send to first party → verify → fill data → send to second party → verify → continue. No invite link is sent.",
|
||||||
|
})
|
||||||
|
async sendPartyOtp(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: SendPartyOtpDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.sendPartyOtpV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("verify-party-otp/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: VerifyPartyOtpDto })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Verify one party's OTP (sequential IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"Verifies a single party's OTP and binds their account. `partyRole` is optional (inferred as FIRST until the first party is bound, then SECOND).",
|
||||||
|
})
|
||||||
|
async verifyPartyOtp(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: VerifyPartyOtpDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.verifyPartyOtpV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/car-body-form/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: CarBodyFormDto })
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] CAR_BODY accident type form" })
|
||||||
|
async carBodyForm(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: CarBodyFormDto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Body("partyRole") partyRole?: string,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.carBodyAccidentTypeFormV2(
|
||||||
|
requestId,
|
||||||
|
body,
|
||||||
|
registrar,
|
||||||
|
partyRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/initial-form/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: AddPlateDto })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Initial form (plate/insurance) for current party",
|
||||||
|
description:
|
||||||
|
"Fills the FIRST or SECOND party plate/insurance/vehicle data depending on the current workflow step.",
|
||||||
|
})
|
||||||
|
async initialForm(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: AddPlateDto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Body("partyRole") partyRole?: string,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.initialFormV2(
|
||||||
|
requestId,
|
||||||
|
body,
|
||||||
|
registrar,
|
||||||
|
partyRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { file: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/video",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `registrar-${file.originalname}-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@Post("upload-video/:requestId")
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] Upload first-party video" })
|
||||||
|
async uploadVideo(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||||
|
return this.requestManagementService.uploadFirstPartyVideoV2(
|
||||||
|
requestId,
|
||||||
|
file,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/add-detail-location/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: LocationDto })
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] Add location for current party" })
|
||||||
|
async addLocation(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: LocationDto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Body("partyRole") partyRole?: string,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.addDetailLocationV2(
|
||||||
|
requestId,
|
||||||
|
body,
|
||||||
|
registrar,
|
||||||
|
partyRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
|
partyRole: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["FIRST", "SECOND"],
|
||||||
|
description:
|
||||||
|
"Optional explicit party selector; must match the party currently collecting voice.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/voice",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const flname = file.originalname.split(".")[0];
|
||||||
|
callback(null, `registrar-${flname}-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@Post("upload-voice/:requestId")
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] Upload voice for current party" })
|
||||||
|
async uploadVoice(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@UploadedFile() voice?: Express.Multer.File,
|
||||||
|
@Body("partyRole") partyRole?: string,
|
||||||
|
) {
|
||||||
|
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||||
|
return this.requestManagementService.uploadVoiceV2(
|
||||||
|
requestId,
|
||||||
|
voice,
|
||||||
|
registrar,
|
||||||
|
partyRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("/add-detail-description/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: DescriptionDto })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Add description for current party",
|
||||||
|
description:
|
||||||
|
"For THIRD_PARTY, submitting the SECOND party description finalizes guilt (FIRST party = guilty) and moves the file straight to WAITING_FOR_SIGNATURES.",
|
||||||
|
})
|
||||||
|
async addDescription(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: DescriptionDto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Body("partyRole") partyRole?: string,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.addDescriptionV2(
|
||||||
|
requestId,
|
||||||
|
body,
|
||||||
|
registrar,
|
||||||
|
partyRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("add-second-party/:phoneNumber/:requestId/")
|
||||||
|
@ApiParam({ name: "phoneNumber" })
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Advance to second party (sends OTP, no link)",
|
||||||
|
description:
|
||||||
|
"Sends an OTP SMS to the second party's phone number and updates the workflow to await verification. No invite link is sent. Use verify-party-otp next.",
|
||||||
|
})
|
||||||
|
async addSecondParty(
|
||||||
|
@Param("phoneNumber") phoneNumber: string,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAdvanceToSecondPartyV2(
|
||||||
|
requestId,
|
||||||
|
registrar,
|
||||||
|
phoneNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("add-accident-fields/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Add accident fields and advance to signatures",
|
||||||
|
description:
|
||||||
|
"Saves accidentWay, accidentReason, and accidentType. " +
|
||||||
|
"The first party is always guilty, so this also advances the workflow " +
|
||||||
|
"from WAITING_FOR_GUILT_DECISION straight to WAITING_FOR_SIGNATURES. " +
|
||||||
|
"Must be called before sign.",
|
||||||
|
})
|
||||||
|
async addAccidentFields(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() fields: ExpertAccidentFieldsDto,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("sign/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[Registrar mirror] Upload a party's on-site signature",
|
||||||
|
description:
|
||||||
|
"Collect each party's signature on site. CAR_BODY: partyRole=FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed (accepted), the blame case completes.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["partyRole", "sign"],
|
||||||
|
properties: {
|
||||||
|
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||||
|
isAccept: { type: "boolean", default: true },
|
||||||
|
sign: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("sign", {
|
||||||
|
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/signs",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
callback(null, `registrar-party-${unique}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async sign(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
// Guard: accident fields must be filled before signing to prevent the
|
||||||
|
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES.
|
||||||
|
const requestData = await this.requestManagementService.getBlameRequestV2(
|
||||||
|
requestId,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||||
|
const partyRole =
|
||||||
|
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||||
|
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
|
||||||
|
return this.requestManagementService.expertUploadPartySignatureV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
partyRole,
|
||||||
|
isAccept,
|
||||||
|
sign,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiOperation({ summary: "[Registrar mirror] Get one blame request" })
|
||||||
|
async getOne(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getBlameRequestV2(
|
||||||
|
requestId,
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ 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";
|
||||||
|
import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -72,7 +74,9 @@ import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
|||||||
RequestManagementV2Controller,
|
RequestManagementV2Controller,
|
||||||
ExpertInitiatedController,
|
ExpertInitiatedController,
|
||||||
ExpertInitiatedV2Controller,
|
ExpertInitiatedV2Controller,
|
||||||
|
ExpertInitiatedBlameMirrorController,
|
||||||
RegistrarInitiatedController,
|
RegistrarInitiatedController,
|
||||||
|
RegistrarBlameMirrorController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
RequestManagementService,
|
RequestManagementService,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,40 +40,49 @@ export class SandHubService {
|
|||||||
return this.systemSettingsService.isSandHubLiveEnabled();
|
return this.systemSettingsService.isSandHubLiveEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tenant-specific company fields for mocked external inquiries (MOCK_INQUIRY_COMPANY_*). */
|
||||||
|
private getMockInquiryCompanyId(): string {
|
||||||
|
return process.env.CLIENT_ID ?? "15";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMockInquiryCompanyName(): string {
|
||||||
|
return process.env.CLIENT_NAME ?? "بیمه سامان";
|
||||||
|
}
|
||||||
|
|
||||||
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
||||||
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
|
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
PrntPlcyCmpDocNo: "1403/1143-70591/200/35",
|
PrntPlcyCmpDocNo: "1404/1143-70591/200/123",
|
||||||
MapTypNam: "پرايد هاچ بک -111",
|
MapTypNam: "فائو ( FAW )",
|
||||||
MtrNum: "5215907",
|
MtrNum: "TZ196XYAP223A210074",
|
||||||
ShsNum: "NAS431100E5798656",
|
ShsNum: "LFP8C7PC3R1K12157",
|
||||||
DisFnYrNum: "0",
|
DisFnYrNum: null,
|
||||||
DisLfYrNum: "0",
|
DisLfYrNum: null,
|
||||||
DisPrsnYrNum: "0",
|
DisPrsnYrNum: null,
|
||||||
DisPrsnYrPrcnt: "0",
|
DisPrsnYrPrcnt: null,
|
||||||
DisFnYrPrcnt: "0",
|
DisFnYrPrcnt: "5",
|
||||||
DisLfYrPrcnt: "0",
|
DisLfYrPrcnt: "5",
|
||||||
vin: "IRPC941V2BD798656",
|
vin: "LFP8C7PC3R1K12157",
|
||||||
MapVehicleSystemName: "ثبت نشده",
|
MapVehicleSystemName: "ثبت نشده",
|
||||||
LfCvrCptl: 0,
|
LfCvrCptl: 0,
|
||||||
FnCvrCptl: 0,
|
FnCvrCptl: 0,
|
||||||
PrsnCvrCptl: 0,
|
PrsnCvrCptl: 0,
|
||||||
VehicleSystemCode: 1,
|
VehicleSystemCode: 1,
|
||||||
EdrsJson: '[{"id":1,"Dsc":" الحاقيه توضيحات ندارد"}]',
|
EdrsJson: "",
|
||||||
PersonCvrCptl: 12000000000,
|
PersonCvrCptl: 12000000000,
|
||||||
LifeCvrCptl: 16000000000,
|
LifeCvrCptl: 16000000000,
|
||||||
FinancialCvrCptl: 4000000000,
|
FinancialCvrCptl: 4000000000,
|
||||||
CarGroupCode: 3,
|
CarGroupCode: 3,
|
||||||
CylCnt: 4,
|
CylCnt: 4,
|
||||||
LastCompanyDocumentNumber: "03/1031/2835/1001/662",
|
LastCompanyDocumentNumber: "31/3100/03/18350",
|
||||||
UsageCode: "8",
|
UsageCode: "8",
|
||||||
MapUsageCode: 1,
|
MapUsageCode: 1,
|
||||||
MapUsageName: "شخصي",
|
MapUsageName: "شخصي",
|
||||||
Plk1: 59,
|
Plk1: 16,
|
||||||
Plk2: 16,
|
Plk2: 12,
|
||||||
Plk3: 419,
|
Plk3: 498,
|
||||||
PlkSrl: 78,
|
PlkSrl: 60,
|
||||||
PrintEndorsCompanyDocumentNumber: "بيمه نامه الحاقيه ندارد.",
|
PrintEndorsCompanyDocumentNumber: "",
|
||||||
EndorseDate: null,
|
EndorseDate: null,
|
||||||
InsuranceFullName: null,
|
InsuranceFullName: null,
|
||||||
SystemField: "سايپا",
|
SystemField: "سايپا",
|
||||||
@@ -81,17 +90,17 @@ export class SandHubService {
|
|||||||
UsageField: "سواري",
|
UsageField: "سواري",
|
||||||
MainColorField: "سفيد",
|
MainColorField: "سفيد",
|
||||||
SecondColorField: "سفيد شيري",
|
SecondColorField: "سفيد شيري",
|
||||||
ModelField: "1394",
|
ModelField: "2024",
|
||||||
CapacityField: "جمعا 4 نفر",
|
CapacityField: "جمعا 4 نفر",
|
||||||
CylinderNumberField: "4",
|
CylinderNumberField: "4",
|
||||||
EngineNumberField: "5215907",
|
EngineNumberField: "5215907",
|
||||||
ChassisNumberField: "NAS431100E5798656",
|
ChassisNumberField: "LFP8C7PC3R1K12157",
|
||||||
VinNumberField: "IRPC941V2BD798656",
|
VinNumberField: "LFP8C7PC3R1K12157",
|
||||||
InstallDateField: "1403/03/01",
|
InstallDateField: "1403/03/01",
|
||||||
AxelNumberField: "2",
|
AxelNumberField: "2",
|
||||||
WheelNumberField: "4",
|
WheelNumberField: "4",
|
||||||
CompanyName: "بیمه سامان",
|
CompanyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||||
CompanyCode: "15",
|
CompanyCode: `${process.env.CLIENT_ID ?? 15}`,
|
||||||
IssueDate: "1403/04/06",
|
IssueDate: "1403/04/06",
|
||||||
SatrtDate: "1403/04/06",
|
SatrtDate: "1403/04/06",
|
||||||
EndDate: "1404/04/06",
|
EndDate: "1404/04/06",
|
||||||
@@ -107,15 +116,56 @@ export class SandHubService {
|
|||||||
SystemCodeCii: 0,
|
SystemCodeCii: 0,
|
||||||
SystemNameCii: "ثبت نشده",
|
SystemNameCii: "ثبت نشده",
|
||||||
TypeCodeCii: 12189,
|
TypeCodeCii: 12189,
|
||||||
TypeNameCii: "پرايد هاچ بک -111",
|
TypeNameCii: "فائو ( FAW )",
|
||||||
UsageNameCii: "سواري",
|
UsageNameCii: "سواري",
|
||||||
UsageCodeCii: 1,
|
UsageCodeCii: 1,
|
||||||
ModelCii: 1394,
|
ModelCii: 2024,
|
||||||
damageTypes: [],
|
damageTypes: [],
|
||||||
StatusTypeCode: 1,
|
StatusTypeCode: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultMockCarBodyInquiryRaw(
|
||||||
|
userDetail: SandHubDetailDto,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const companyId = Number(this.getMockInquiryCompanyId());
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
id: 70016075946,
|
||||||
|
printNumber: "1405/1143-70591/220/1/0",
|
||||||
|
companyId: Number.isNaN(companyId) ? 15 : companyId,
|
||||||
|
companyName: this.getMockInquiryCompanyName(),
|
||||||
|
beginDate: "1405/01/17",
|
||||||
|
endDate: "1406/01/17",
|
||||||
|
issueDate: "1405/01/16",
|
||||||
|
hasEndorsement: null,
|
||||||
|
motorNumber: "TZ196XYAP223A210074",
|
||||||
|
chassisNumber: "LFP8C7PC3R1K12157",
|
||||||
|
vin: "LFP8C7PC3R1K12157",
|
||||||
|
plateTypeId: 9,
|
||||||
|
plateTypeTitle: "پلاک قدیمی",
|
||||||
|
platePartOne: 16,
|
||||||
|
plateSerialNumber: 60,
|
||||||
|
plateLetterid: 12,
|
||||||
|
plateLetterTitle: "م ",
|
||||||
|
vehicleSystemTitle: "فائو ( FAW )",
|
||||||
|
vehicleGroupId: 2,
|
||||||
|
vehicleGroupTitle: "سواری چهار سیلندر",
|
||||||
|
insurerNationalCode: userDetail.nationalCodeOfInsurer,
|
||||||
|
insurerName: "هانيه کارخانهء",
|
||||||
|
ownerNationalCode: userDetail.nationalCodeOfInsurer,
|
||||||
|
previousId: 70006331822,
|
||||||
|
noLossYearsCount: 4,
|
||||||
|
platePartThree: 498,
|
||||||
|
lossDocuments: [],
|
||||||
|
},
|
||||||
|
isSuccess: true,
|
||||||
|
statusCode: 200,
|
||||||
|
message: "عملیات با موفقیت انجام شد",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
||||||
*/
|
*/
|
||||||
@@ -401,30 +451,7 @@ export class SandHubService {
|
|||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
|
`[MOCK] getTejaratCarBodyInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
);
|
);
|
||||||
raw = {
|
raw = this.getDefaultMockCarBodyInquiryRaw(userDetail);
|
||||||
data: {
|
|
||||||
printNumber: "MOCK-BADANE-001",
|
|
||||||
companyId: 34,
|
|
||||||
companyName: "بیمه تجارت نو",
|
|
||||||
beginDate: "1404/06/15",
|
|
||||||
endDate: "1405/06/15",
|
|
||||||
issueDate: "1404/06/13",
|
|
||||||
hasEndorsement: null,
|
|
||||||
motorNumber: "MOCK-ENGINE",
|
|
||||||
chassisNumber: "MOCK-CHASSIS",
|
|
||||||
vin: "MOCK-VIN",
|
|
||||||
vehicleSystemTitle: "سایپا",
|
|
||||||
vehicleGroupTitle: "سواری چهار سیلندر",
|
|
||||||
insurerNationalCode: userDetail.nationalCodeOfInsurer,
|
|
||||||
insurerName: "نام آزمایشی",
|
|
||||||
ownerNationalCode: userDetail.nationalCodeOfInsurer,
|
|
||||||
noLossYearsCount: 0,
|
|
||||||
lossDocuments: [],
|
|
||||||
},
|
|
||||||
isSuccess: true,
|
|
||||||
statusCode: 200,
|
|
||||||
message: "mock-ok",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = this.mapCarBodyInquiryResponse(raw);
|
const mapped = this.mapCarBodyInquiryResponse(raw);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildInviteLink(frontendRoute: string, requestId: string): string {
|
buildInviteLink(frontendRoute: string, requestId: string): string {
|
||||||
return `${process.env.URL}/${frontendRoute}?token=${requestId}`;
|
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${frontendRoute}?token=${requestId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildBlamePartyLink(
|
buildBlamePartyLink(
|
||||||
@@ -46,11 +46,11 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
partyRole: "FIRST" | "SECOND",
|
partyRole: "FIRST" | "SECOND",
|
||||||
): string {
|
): string {
|
||||||
const route = partyRole === "SECOND" ? "user2" : "user";
|
const route = partyRole === "SECOND" ? "user2" : "user";
|
||||||
return `${process.env.URL}/${route}?token=${requestId}`;
|
return `${process.env.URL}/${process.env.USER_BASE_PATH}/${route}?token=${requestId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildClaimLink(claimRequestId: string): string {
|
buildClaimLink(claimRequestId: string): string {
|
||||||
return `${process.env.URL}/caseClaim?token=${claimRequestId}`;
|
return `${process.env.URL}/${process.env.USER_BASE_PATH}/caseClaim?token=${claimRequestId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteLink(
|
async sendInviteLink(
|
||||||
|
|||||||
Reference in New Issue
Block a user