forked from Yara724/api
Compare commits
125 Commits
integrate-
...
908292b0c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 908292b0c3 | |||
| 55ec6f1fd1 | |||
| d33cff8438 | |||
| 0bb13f4596 | |||
| 70306d42e0 | |||
| d9dc4ecdff | |||
| f66fa5d7b4 | |||
| 6429cb0f2b | |||
| 6f120b0066 | |||
| 8419ec06ae | |||
| def4023185 | |||
| 40606fecf1 | |||
| c567f93e85 | |||
| a9846095c1 | |||
| b6f0e3c821 | |||
| bffb8a3b97 | |||
| 715a9f2467 | |||
| 3813c2b3e3 | |||
| ebf8a9a624 | |||
| 993d809de2 | |||
| 80ef885d3b | |||
|
|
3f608f63f1 | ||
| 6019c9e954 | |||
|
|
8ae6f2c91b | ||
|
|
f999313476 | ||
|
|
98f1d2caf5 | ||
|
|
bbd83da2d5 | ||
|
|
9296795166 | ||
|
|
f456443342 | ||
|
|
bcedd8c6f3 | ||
| 8caf13cf18 | |||
|
|
e90f8dc33c | ||
| 4c4b1a1db7 | |||
|
|
c2f996cc28 | ||
| 362e02ddc4 | |||
|
|
4b0e8a3547 | ||
|
|
885678df7d | ||
| 8dffc46357 | |||
|
|
b5b3b722c6 | ||
|
|
4f8cb43883 | ||
|
|
6c2d178686 | ||
| 9854a58282 | |||
|
|
7184142137 | ||
| d45975b6a7 | |||
|
|
05c5b70b4d | ||
|
|
5d2227b00b | ||
| eef305c42e | |||
|
|
9c87927e6c | ||
| 5618d120e3 | |||
|
|
d9b1537ee4 | ||
| f01882dc3a | |||
|
|
8284aba825 | ||
| 3c15901ecc | |||
|
|
1e1edf9136 | ||
| daad9539ea | |||
|
|
194b71cdb2 | ||
|
|
04ba46ed2a | ||
| c6f7edabd2 | |||
| 75b7e5ecad | |||
| a3156881b8 | |||
| 41c44dcf23 | |||
|
|
95bfc095ce | ||
| b63c2155bc | |||
| 9463142ecf | |||
| a71c4ea980 | |||
|
|
b826a133a4 | ||
| b4501c503e | |||
|
|
a0d337aff0 | ||
| 28e2de459a | |||
| af1a07743e | |||
| fca88bc151 | |||
|
|
e264695db3 | ||
| 277f2d4d66 | |||
|
|
514018beb1 | ||
| 374b15933d | |||
| 08a4d754c1 | |||
| c5b2d7b520 | |||
|
|
5a84080bf1 | ||
| 68b3b2000b | |||
|
|
028ca899fe | ||
|
|
0086b8db4d | ||
|
|
7f5b64f2a6 | ||
| 2898897dd9 | |||
| e8a3873851 | |||
| 131df63a3f | |||
| b9eb0ab5bd | |||
| 4bdb9fd469 | |||
| 38f700c3d7 | |||
|
|
1a1d55bc2e | ||
| a2f1d5ea7f | |||
|
|
9a65071276 | ||
| 12d6fa4d73 | |||
|
|
640b240ada | ||
|
|
f8fbbb7ac6 | ||
|
|
0e2789c209 | ||
| 7661862564 | |||
|
|
a2143fe1c5 | ||
|
|
494e3d93ab | ||
| 9afb079786 | |||
| 25958bb966 | |||
|
|
1d51370b3e | ||
| e44721b7be | |||
| 7ef057dc1f | |||
| 1b68a81204 | |||
| f77da50d1f | |||
| 79c2982e19 | |||
| fb4d166c31 | |||
| 9084f8fafb | |||
| b216a363fb | |||
|
|
b2391751f0 | ||
| df6d2bd04d | |||
| f277d87dfa | |||
| 672ec25438 | |||
| aa871c86f6 | |||
| 553a34054c | |||
| 8af152abc0 | |||
| 0446c83b36 | |||
| 255cfd3eb3 | |||
| b1be5b1e09 | |||
| 7d3565ec51 | |||
| fc599acf4a | |||
|
|
b40270f058 | ||
|
|
8d6fa3daac | ||
| 64dfd1ca8a | |||
| 665ed9e70f |
2897
package-lock.json
generated
2897
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"@nestjs-modules/mailer": "^1.8.1",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
@@ -44,6 +45,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.2",
|
||||
|
||||
@@ -3,6 +3,9 @@ export enum WorkflowStep {
|
||||
|
||||
CREATED = "CREATED",
|
||||
|
||||
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||
|
||||
// ---------- First party ----------
|
||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||
FIRST_VIDEO = "FIRST_VIDEO",
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
//! NEW
|
||||
export enum CaseStatus {
|
||||
|
||||
OPEN = "OPEN",
|
||||
OPEN = "OPEN",
|
||||
|
||||
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
||||
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
|
||||
|
||||
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
||||
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
|
||||
|
||||
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
||||
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
|
||||
|
||||
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
||||
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
||||
|
||||
COMPLETED = "COMPLETED",
|
||||
COMPLETED = "COMPLETED",
|
||||
|
||||
CANCELLED = "CANCELLED",
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
AUTO_CLOSED = "AUTO_CLOSED"
|
||||
}
|
||||
AUTO_CLOSED = "AUTO_CLOSED",
|
||||
|
||||
STOPPED = "STOPPED"
|
||||
}
|
||||
92
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
92
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ResendItemType } from "./resendItemType.enum";
|
||||
|
||||
const RESEND_ITEM_VALUES = new Set<string>(Object.values(ResendItemType));
|
||||
|
||||
/**
|
||||
* Map multipart / client field names and DB typos to canonical {@link ResendItemType} values.
|
||||
*/
|
||||
export function normalizeResendRequestedItemKey(raw: string): string | null {
|
||||
const t = String(raw ?? "").trim();
|
||||
if (!t) return null;
|
||||
if (RESEND_ITEM_VALUES.has(t)) return t;
|
||||
const lower = t.toLowerCase();
|
||||
for (const v of RESEND_ITEM_VALUES) {
|
||||
if (v.toLowerCase() === lower) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deduplicated list of valid requested item keys. */
|
||||
export function normalizeResendRequestedItemsList(
|
||||
items: string[] | undefined | null,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of items || []) {
|
||||
const c = normalizeResendRequestedItemKey(String(raw));
|
||||
if (c && !seen.has(c)) {
|
||||
seen.add(c);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone `uploadedDocuments` from a Mongoose subdoc (plain object or Map) into a plain object
|
||||
* so merges and {@link isResendPartyItemSatisfied} see existing keys.
|
||||
*/
|
||||
export function cloneResendUploadedDocuments(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (raw == null || typeof raw !== "object") return {};
|
||||
if (raw instanceof Map) {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const [k, v] of raw.entries()) {
|
||||
o[String(k)] = v;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
||||
|
||||
export function getResendItemInputKind(item: string): ResendItemInputKind {
|
||||
if (item === ResendItemType.VOICE) return "voice";
|
||||
if (item === ResendItemType.VIDEO) return "video";
|
||||
if (item === ResendItemType.DESCRIPTION) return "text";
|
||||
return "document_camera";
|
||||
}
|
||||
|
||||
export function buildResendItemsWithUi(requestedItems: string[]) {
|
||||
const normalized = normalizeResendRequestedItemsList(requestedItems);
|
||||
return normalized.map((item) => ({
|
||||
item,
|
||||
inputKind: getResendItemInputKind(item),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whether a single requested item is satisfied on a party's resend row (after merges). */
|
||||
export function isResendPartyItemSatisfied(
|
||||
item: string,
|
||||
row: {
|
||||
uploadedDocuments?: Record<string, unknown>;
|
||||
resendVoiceId?: unknown;
|
||||
resendVideoId?: unknown;
|
||||
userTextDescription?: string;
|
||||
},
|
||||
): boolean {
|
||||
const uploaded = row.uploadedDocuments || {};
|
||||
if (item === ResendItemType.DESCRIPTION) {
|
||||
return !!(row.userTextDescription && String(row.userTextDescription).trim());
|
||||
}
|
||||
if (item === ResendItemType.VOICE) {
|
||||
return !!row.resendVoiceId;
|
||||
}
|
||||
if (item === ResendItemType.VIDEO) {
|
||||
return !!row.resendVideoId;
|
||||
}
|
||||
return !!uploaded[item];
|
||||
}
|
||||
@@ -5,8 +5,12 @@ export enum ResendItemType {
|
||||
CAR_CERTIFICATE = "carCertificate",
|
||||
DRIVING_LICENSE = "drivingLicense",
|
||||
CAR_GREEN_CARD = "carGreenCard",
|
||||
|
||||
PLATE = "plate",
|
||||
CAR_PLATE = "carPlate",
|
||||
// Media evidence
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
|
||||
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||
DESCRIPTION = "description",
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export enum ClaimCaseStatus {
|
||||
// User flow - damage capture
|
||||
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
|
||||
|
||||
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
|
||||
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
|
||||
|
||||
// Expert flow
|
||||
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
|
||||
EXPERT_REVIEWING = "EXPERT_REVIEWING",
|
||||
|
||||
@@ -7,17 +7,22 @@ export enum ClaimWorkflowStep {
|
||||
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
|
||||
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
|
||||
|
||||
// User: Documentation phase
|
||||
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
||||
|
||||
// User: Damage capture phase (per part)
|
||||
// User: Damage capture phase (angles + per-part photos) — before document upload in v2
|
||||
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
|
||||
|
||||
// User: Documentation phase — after captures in v2
|
||||
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
|
||||
|
||||
// User submission complete
|
||||
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
|
||||
|
||||
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
|
||||
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
|
||||
|
||||
// Expert: Damage assessment
|
||||
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
|
||||
/** After user objection: damage expert’s last priced reply (stored in evaluation.damageExpertReplyFinal) */
|
||||
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
|
||||
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||
|
||||
// Insurer approval
|
||||
|
||||
@@ -3,4 +3,6 @@ export enum InPersonDocumentsEnum {
|
||||
CarCertificate = "carCertificate",
|
||||
DrivingLicense = "drivingLicense",
|
||||
CarGreenCard = "carGreenCard",
|
||||
Plate = "plate",
|
||||
CarPlate = "carPlate",
|
||||
}
|
||||
@@ -2,6 +2,9 @@ export enum ClaimRequiredDocumentType {
|
||||
// Car green card
|
||||
CAR_GREEN_CARD = "car_green_card",
|
||||
|
||||
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
|
||||
NATIONAL_CARD = "national_card",
|
||||
|
||||
// Damaged party documents
|
||||
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
|
||||
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum RoleEnum {
|
||||
EXPERT = "expert",
|
||||
DAMAGE_EXPERT = "damage_expert",
|
||||
FIELD_EXPERT = "field_expert",
|
||||
REGISTRAR = "registrar",
|
||||
COMPANY = "company",
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AiService implements OnModuleInit {
|
||||
username: process.env.AI_USERNAME,
|
||||
password: process.env.AI_PASSWORD,
|
||||
},
|
||||
timeout: 30000, // 30 second timeout
|
||||
timeout: 1000, // 30 second timeout
|
||||
};
|
||||
|
||||
private get profileOptions(): AxiosRequestConfig {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SandHubModule } from "./sand-hub/sand-hub.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
import { RequestManagementModule } from "./request-management/request-management.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
||||
import { CronModule } from "./utils/cron/cron.module";
|
||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||
|
||||
@@ -44,6 +45,10 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
authMechanism: "SCRAM-SHA-256",
|
||||
tls: true,
|
||||
tlsAllowInvalidCertificates: true,
|
||||
connectionFactory: (connection) => {
|
||||
applyIranFaTimestampPlugin(connection);
|
||||
return connection;
|
||||
},
|
||||
},
|
||||
),
|
||||
UsersModule,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
||||
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
|
||||
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
|
||||
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
|
||||
import {
|
||||
GenuineRegisterDto,
|
||||
InsurerRegisterDto,
|
||||
@@ -64,6 +65,14 @@ export class ActorAuthController {
|
||||
return await this.actorAuthService.createFieldExpertMock(body);
|
||||
}
|
||||
|
||||
/** Mock: create a registrar for testing. Make private later. */
|
||||
@Post("create-registrar")
|
||||
@ApiBody({ type: CreateRegistrarDto })
|
||||
@ApiAcceptedResponse()
|
||||
async createRegistrar(@Body() body: CreateRegistrarDto) {
|
||||
return await this.actorAuthService.createRegistrarMock(body);
|
||||
}
|
||||
|
||||
@UseGuards(LocalActorAuthGuard)
|
||||
@Post("login")
|
||||
@Roles()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
// import { MailService } from "src/utils/mail/mail.service";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
@@ -46,6 +47,7 @@ export class ActorAuthService {
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly registrarDbService: RegistrarDbService,
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
@@ -79,6 +81,13 @@ export class ActorAuthService {
|
||||
else
|
||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
res = await this.registrarDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.registrarDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.COMPANY:
|
||||
res = await this.insurerExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
@@ -273,6 +282,10 @@ export class ActorAuthService {
|
||||
actor = await this.fieldExpertDbService.findOne(filter);
|
||||
dbServiceToUpdate = this.fieldExpertDbService as any;
|
||||
}
|
||||
if (!actor) {
|
||||
actor = await this.registrarDbService.findOne(filter);
|
||||
dbServiceToUpdate = this.registrarDbService as any;
|
||||
}
|
||||
|
||||
if (!actor) {
|
||||
throw new NotFoundException("actor not found");
|
||||
@@ -309,6 +322,10 @@ export class ActorAuthService {
|
||||
userExist = await this.fieldExpertDbService.findOne({ email });
|
||||
dbServiceToUpdate = this.fieldExpertDbService;
|
||||
}
|
||||
if (!userExist) {
|
||||
userExist = await this.registrarDbService.findOne({ email });
|
||||
dbServiceToUpdate = this.registrarDbService as any;
|
||||
}
|
||||
if (!userExist) throw new NotFoundException("user not found");
|
||||
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
||||
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
||||
@@ -383,6 +400,7 @@ export class ActorAuthService {
|
||||
"phone",
|
||||
"mobile",
|
||||
],
|
||||
registrar: ["email"],
|
||||
};
|
||||
|
||||
const allowedFields = allowedFieldsByRole[role];
|
||||
@@ -498,4 +516,34 @@ export class ActorAuthService {
|
||||
throw er;
|
||||
}
|
||||
}
|
||||
|
||||
async createRegistrarMock(body: {
|
||||
email: string;
|
||||
password: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
const hashPassword = await this.hashService.hash(body.password);
|
||||
const payload = {
|
||||
email: body.email.toLowerCase().trim(),
|
||||
password: hashPassword,
|
||||
clientKey: new Types.ObjectId(body.clientId),
|
||||
role: RoleEnum.REGISTRAR,
|
||||
};
|
||||
try {
|
||||
const created = await this.registrarDbService.create(payload as any);
|
||||
return {
|
||||
id: (created as any)._id,
|
||||
email: (created as any).email,
|
||||
clientKey: (created as any).clientKey,
|
||||
role: (created as any).role,
|
||||
};
|
||||
} catch (er) {
|
||||
if (er.code === 11000) {
|
||||
throw new BadRequestException(
|
||||
"A registrar with this email already exists.",
|
||||
);
|
||||
}
|
||||
throw er;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { OtpService } from "src/utils/otp/otp.service";
|
||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
|
||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||
@Injectable()
|
||||
@@ -25,7 +25,7 @@ export class UserAuthService {
|
||||
private readonly userDbService: UserDbService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly otpCreator: OtpService,
|
||||
private readonly smsManagerService: SmsManagerService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
) {}
|
||||
|
||||
async validateUser(username: string, pass: string): Promise<any> {
|
||||
@@ -71,6 +71,12 @@ export class UserAuthService {
|
||||
});
|
||||
const otp = this.otpCreator.create();
|
||||
const hashOtp = await this.hashService.hash(otp);
|
||||
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
|
||||
const expireMinutes =
|
||||
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
|
||||
? rawExpireMinutes
|
||||
: 2;
|
||||
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
|
||||
if (!userExist) {
|
||||
await this.smsSender(otp, mobile);
|
||||
/// create otp request
|
||||
@@ -90,9 +96,7 @@ export class UserAuthService {
|
||||
city: "",
|
||||
address: "",
|
||||
state: "",
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
});
|
||||
return new LoginDtoRs(newUser);
|
||||
}
|
||||
@@ -105,9 +109,7 @@ export class UserAuthService {
|
||||
},
|
||||
{
|
||||
otp: hashOtp,
|
||||
otpExpire: new Date(
|
||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
||||
).getTime(),
|
||||
otpExpire,
|
||||
},
|
||||
);
|
||||
if (updateTokens) return new LoginDtoRs(userExist);
|
||||
@@ -115,25 +117,19 @@ export class UserAuthService {
|
||||
}
|
||||
|
||||
private async smsSender(otp: string, mobile: string) {
|
||||
return this.smsManagerService
|
||||
.verifyLookUp({
|
||||
token: otp,
|
||||
template: process.env.AUTH_SMS_TEMPLATE,
|
||||
receptor: mobile,
|
||||
})
|
||||
.then((smsRes) => {
|
||||
this.logger.log(
|
||||
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
|
||||
);
|
||||
})
|
||||
.catch((er) => {
|
||||
this.logger.error(
|
||||
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
|
||||
);
|
||||
throw new HttpException(
|
||||
" auth sms send failed",
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
});
|
||||
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||
mobile,
|
||||
otp,
|
||||
process.env.AUTH_SMS_TEMPLATE,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new HttpException(
|
||||
"auth sms send failed",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
// import { MailModule } from "src/utils/mail/mail.module";
|
||||
import { OtpModule } from "src/utils/otp/otp.module";
|
||||
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,7 +22,7 @@ import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||
HashModule,
|
||||
OtpModule,
|
||||
PassportModule,
|
||||
SmsManagerModule,
|
||||
SmsOrchestrationModule,
|
||||
JwtModule.register({
|
||||
signOptions: { expiresIn: "1h" },
|
||||
global: true,
|
||||
|
||||
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
18
src/auth/dto/actor/create-registrar.actor.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsMongoId, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateRegistrarDto {
|
||||
@ApiProperty({ example: "registrar@sample.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "securePassword123", minLength: 6 })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "507f1f77bcf86cd799439011" })
|
||||
@IsMongoId()
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
RoleEnum.DAMAGE_EXPERT,
|
||||
RoleEnum.COMPANY,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
].includes(payload.role)
|
||||
) {
|
||||
throw new UnauthorizedException("User role is not authorized");
|
||||
|
||||
@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
});
|
||||
if (payload.role !== RoleEnum.USER) {
|
||||
console.log(
|
||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
||||
request.user.role,
|
||||
);
|
||||
|
||||
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
request.user = payload;
|
||||
request.identity = request.user;
|
||||
} catch {
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { diskStorage } from "multer";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
@@ -49,6 +49,7 @@ export class ClaimRequestManagementController {
|
||||
) {}
|
||||
|
||||
@ApiParam({ name: "blameId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("/:blameId")
|
||||
async createClaimRequest(
|
||||
@Param("blameId") requestId: string,
|
||||
@@ -62,6 +63,7 @@ export class ClaimRequestManagementController {
|
||||
}
|
||||
|
||||
@ApiBody({ type: CarDamagePartDto })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-part-damage/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async carPartDamage(
|
||||
@@ -76,6 +78,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/car-other-part")
|
||||
async getCarOtherParts() {
|
||||
const carOtherPart = await readFile(
|
||||
@@ -104,6 +107,7 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("/car-other-part-damage/:claimRequestID")
|
||||
async carOtherPartDamage(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -119,6 +123,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(
|
||||
@@ -129,6 +134,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("car-part-image-required/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||
@@ -171,6 +177,7 @@ export class ClaimRequestManagementController {
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description: "Type of required document to upload",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("upload-required-document/:claimRequestID")
|
||||
async uploadRequiredDocument(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -223,6 +230,7 @@ export class ClaimRequestManagementController {
|
||||
name: "partId",
|
||||
description: "The ID of the specific car part being photographed.",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||
async captureCarPartDamage(
|
||||
@Param("partId") partId: string,
|
||||
@@ -263,6 +271,7 @@ export class ClaimRequestManagementController {
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("car-capture/:claimRequestID")
|
||||
async captureVideoCapture(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -274,11 +283,13 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("requests/")
|
||||
async getRequest(@CurrentUser() currentUser) {
|
||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
myRequests(
|
||||
@@ -288,6 +299,7 @@ export class ClaimRequestManagementController {
|
||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/reply/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@UseInterceptors(
|
||||
@@ -326,6 +338,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/resend/:claimRequestId/objection")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("application/json")
|
||||
@@ -343,6 +356,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/resend/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -393,6 +407,7 @@ export class ClaimRequestManagementController {
|
||||
* User satisfaction rating for a completed claim file.
|
||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||
*/
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@@ -408,6 +423,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@@ -452,6 +468,7 @@ export class ClaimRequestManagementController {
|
||||
|
||||
@ApiBody({ type: InPersonVisitDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(
|
||||
@Param("id") requestId: string,
|
||||
@@ -466,6 +483,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
@@ -473,6 +491,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||
@@ -481,6 +500,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||
@@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt";
|
||||
ClaimRequiredDocumentDbService,
|
||||
ClaimAccessGuard,
|
||||
],
|
||||
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
|
||||
controllers: [
|
||||
ClaimRequestManagementController,
|
||||
ClaimRequestManagementV2Controller,
|
||||
RegistrarClaimV1Controller,
|
||||
],
|
||||
exports: [
|
||||
ClaimRequestManagementService,
|
||||
ClaimRequestManagementDbService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,38 +2,54 @@ import {
|
||||
Controller,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
BadRequestException,
|
||||
Param,
|
||||
Query,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { Types } from "mongoose";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
OuterPartCatalogItemDto,
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||||
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
VideoCaptureV2ResponseDto,
|
||||
} from "./dto/capture-part-v2.dto";
|
||||
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
|
||||
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
|
||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||
import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
|
||||
@ApiTags("claim-request-management (v2)")
|
||||
@Controller("v2/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.USER)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
@@ -51,7 +67,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
|
||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -68,7 +84,8 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get Claim Details (V2)",
|
||||
description: "Get full details of a claim request. Only the claim owner can access.",
|
||||
description:
|
||||
"Get claim details for current actor. USER (claim owner) receives the same core claim payload including `owner` (userId, clientId, optional userClientKey). FIELD_EXPERT additionally receives unmasked money and userRating when present.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -91,6 +108,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
return await this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -100,6 +118,205 @@ export class ClaimRequestManagementV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos).
|
||||
*/
|
||||
@Post("request/:claimRequestId/expert-resend/acknowledge")
|
||||
@ApiOperation({
|
||||
summary: "Acknowledge expert resend (instructions only)",
|
||||
description:
|
||||
"Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " +
|
||||
"Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
||||
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
|
||||
async acknowledgeExpertResend(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||
*/
|
||||
@Put("request/:claimRequestId/objection")
|
||||
@ApiOperation({
|
||||
summary: "Submit user objection (V2)",
|
||||
description:
|
||||
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
|
||||
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
|
||||
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserObjectionV2Dto })
|
||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||
async submitUserObjectionV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UserObjectionV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit objection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
|
||||
*/
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiOperation({
|
||||
summary: "Submit user satisfaction rating (V2)",
|
||||
description:
|
||||
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
|
||||
"Stores scores on `ClaimCase.userRating` (0–5). One submission per case.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||
async addUserRatingV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() ratingDto: UserRatingDto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.addUserRatingV2(
|
||||
claimRequestId,
|
||||
ratingDto,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to save rating",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Owner signs acceptance of final expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW).
|
||||
*/
|
||||
@Put("request/:claimRequestId/owner-insurer-approval/sign")
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "Claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Sign final claim pricing (owner)",
|
||||
description:
|
||||
"Multipart: `sign` (signature image), `agree` (boolean), and `branchId` (Mongo ObjectId of the insurer branch the file is reviewed under). " +
|
||||
"Allowed only when `status` is WAITING_FOR_INSURER_APPROVAL, " +
|
||||
"`workflow.currentStep` is INSURER_REVIEW (not EXPERT_COST_EVALUATION), and `claimStatus` is APPROVED. " +
|
||||
"If `agree` is true, the case moves to COMPLETED; if false, to REJECTED.",
|
||||
})
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: {
|
||||
type: "boolean",
|
||||
description: "true to accept expert pricing and complete the claim",
|
||||
},
|
||||
branchId: {
|
||||
type: "string",
|
||||
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
||||
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Already signed" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerInsurerApprovalSignV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(claimRequestId)) {
|
||||
throw new BadRequestException("Invalid claim request id");
|
||||
}
|
||||
const agreed =
|
||||
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({
|
||||
name: "blameRequestId",
|
||||
@@ -127,6 +344,66 @@ export class ClaimRequestManagementV2Controller {
|
||||
/**
|
||||
* V2 API: Select damaged outer car parts (Step 2 of claim workflow)
|
||||
*/
|
||||
@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,
|
||||
): Promise<OuterPartCatalogItemDto[]> {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@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.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "insuranceId",
|
||||
description: "Insurer client id (MongoDB ObjectId)",
|
||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of branches for insurer",
|
||||
})
|
||||
async getInsuranceBranchesV2(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@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 getCarOtherPartsV2() {
|
||||
const raw = await readFile(
|
||||
`${process.cwd()}/src/static/car-part.json`,
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
@@ -161,41 +438,35 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description: "Array of selected damaged outer parts",
|
||||
description:
|
||||
"Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Minor front damage",
|
||||
summary: "Sedan - minor front damage",
|
||||
value: {
|
||||
selectedParts: ["hood", "front_bumper", "front_right_fender"],
|
||||
carType: "sedan",
|
||||
selectedPartIds: [19, 21, 16],
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "Side impact damage",
|
||||
summary: "SUV - left side impact",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"front_left_door",
|
||||
"rear_left_door",
|
||||
"front_left_fender",
|
||||
"rear_left_fender",
|
||||
],
|
||||
carType: "suv",
|
||||
selectedPartIds: [102, 103, 104, 107],
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Rear-end collision",
|
||||
summary: "Hatchback - rear-end collision",
|
||||
value: {
|
||||
selectedParts: ["rear_bumper", "trunk", "rear_right_fender"],
|
||||
carType: "hatchback",
|
||||
selectedPartIds: [225, 226, 210],
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Multiple damage areas",
|
||||
summary: "Pickup - two sides + roof",
|
||||
value: {
|
||||
selectedParts: [
|
||||
"hood",
|
||||
"front_bumper",
|
||||
"front_right_door",
|
||||
"rear_bumper",
|
||||
"trunk",
|
||||
],
|
||||
carType: "pickup",
|
||||
selectedPartIds: [319, 312, 330],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -231,6 +502,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -252,13 +524,14 @@ export class ClaimRequestManagementV2Controller {
|
||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||
|
||||
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
||||
Optional: upload car green card file in the same step.
|
||||
|
||||
**Validations:**
|
||||
- Claim must exist
|
||||
- User must be the claim owner (damaged party from blame case)
|
||||
- Current workflow step must be SELECT_OTHER_PARTS
|
||||
- Bank information must not have been submitted previously
|
||||
- Sheba number must be exactly 24 digits
|
||||
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||
- National code must be exactly 10 digits
|
||||
|
||||
**Valid Other Parts (Optional):**
|
||||
@@ -267,8 +540,8 @@ export class ClaimRequestManagementV2Controller {
|
||||
- headlight, taillight, mirror, glass
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4)
|
||||
- User can proceed to upload required documents
|
||||
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||
- User captures car angles and damaged-part photos, then uploads required documents (Step 5)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -276,42 +549,38 @@ export class ClaimRequestManagementV2Controller {
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
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({
|
||||
type: SelectOtherPartsV2Dto,
|
||||
description: "Other parts selection and bank information",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Only bank info (no other parts damaged)",
|
||||
value: {
|
||||
otherParts: [],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "Engine and suspension damage",
|
||||
value: {
|
||||
otherParts: ["engine", "suspension"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Multiple systems damaged",
|
||||
value: {
|
||||
otherParts: ["engine", "brake_system", "electrical", "headlight"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Lighting and glass damage",
|
||||
value: {
|
||||
otherParts: ["headlight", "taillight", "mirror", "glass"],
|
||||
shebaNumber: "123456789012345678901234",
|
||||
nationalCodeOfOwner: "1234567890",
|
||||
description:
|
||||
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. 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({
|
||||
@@ -339,12 +608,15 @@ export class ClaimRequestManagementV2Controller {
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
file,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -369,6 +641,8 @@ export class ClaimRequestManagementV2Controller {
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
|
||||
**V2 order:** Complete angles and part photos first, then required documents.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -397,6 +671,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -430,20 +705,19 @@ Returns status of each item (uploaded/captured or not).
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload Required Document (V2 - Step 4)",
|
||||
summary: "Upload Required Document (V2 - Step 5)",
|
||||
description: `
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||
|
||||
**Upload one of the 13 required documents:**
|
||||
- car_green_card
|
||||
**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
|
||||
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||
|
||||
**When all 13 documents are uploaded:**
|
||||
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
|
||||
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -459,7 +733,6 @@ Returns status of each item (uploaded/captured or not).
|
||||
documentKey: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"car_green_card",
|
||||
"damaged_driving_license_front",
|
||||
"damaged_driving_license_back",
|
||||
"damaged_chassis_number",
|
||||
@@ -473,7 +746,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
],
|
||||
example: "car_green_card",
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
@@ -507,6 +780,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -538,15 +812,17 @@ Returns status of each item (uploaded/captured or not).
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)",
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||
description: `
|
||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim)
|
||||
**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
|
||||
|
||||
**All captures must be completed before user can submit claim.**
|
||||
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||
|
||||
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
@@ -598,6 +874,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
body,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -606,4 +883,129 @@ Returns status of each item (uploaded/captured or not).
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Upload repair factor for a part (same intent as v1 PATCH …/request/reply/:claimRequestId/:partId/upload-factor).
|
||||
*/
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload factor file for a priced part (V2)",
|
||||
description:
|
||||
"Use when the damage expert reply marks `factorNeeded: true` for this `partId`. " +
|
||||
"Stores file in `claim-factors-image`, sets `factorLink` / `factorStatus` on the matching part in " +
|
||||
"`evaluation.damageExpertReply` or `evaluation.damageExpertReplyFinal`. " +
|
||||
"Requires claim `claimStatus` NEEDS_REVISION or UNDER_REVIEW.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId", description: "Claim case ID" })
|
||||
@ApiParam({ name: "partId", description: "Part id from expert reply" })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-factors",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
callback(null, `-${unique}-${file.originalname}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async uploadFactorForPartV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Param("partId") partId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
try {
|
||||
return await this.claimRequestManagementService.uploadClaimFactorV2(
|
||||
claimRequestId,
|
||||
partId,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload factor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API: Upload car walk-around video (same behavior as v1 PATCH claim-request-management/car-capture/:id)
|
||||
*/
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const filename = `claim-video-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Upload car walk-around video (V2)",
|
||||
description:
|
||||
"Multipart upload of the car capture video during CAPTURE_PART_DAMAGES. " +
|
||||
"Stores file metadata in `claim-video-capture` and sets `ClaimCase.media.videoCaptureId`. " +
|
||||
"Only one video per claim; path is stored on the video document (not duplicated on the case).",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Video uploaded successfully",
|
||||
type: VideoCaptureV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||
async captureVideoCaptureV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<VideoCaptureV2ResponseDto> {
|
||||
try {
|
||||
return await this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
file,
|
||||
user.sub,
|
||||
user,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
|
||||
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
|
||||
example: 'Angle captured successfully. 6 captures remaining.',
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for car walk-around video upload (V2)
|
||||
*/
|
||||
export class VideoCaptureV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim case ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the stored video document (claim-video-capture)',
|
||||
example: '507f1f77bcf86cd799439012',
|
||||
})
|
||||
videoId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Video capture uploaded successfully.',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for required document item
|
||||
@@ -93,6 +94,12 @@ export class DamagedPartItem {
|
||||
example: false,
|
||||
})
|
||||
captured: boolean;
|
||||
|
||||
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
|
||||
@ApiPropertyOptional({ example: 12 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/** Active damage-expert resend request (owner must upload or acknowledge). */
|
||||
export class ExpertResendDetailsV2Dto {
|
||||
@ApiPropertyOptional()
|
||||
resendDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
resendDocuments?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
|
||||
fulfilledAt?: Date;
|
||||
}
|
||||
|
||||
export class ClaimDetailsV2ResponseDto {
|
||||
@ApiProperty({ description: 'Claim case ID' })
|
||||
claimRequestId: string;
|
||||
@@ -28,9 +43,13 @@ export class ClaimDetailsV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Blame request number' })
|
||||
blameRequestNo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner info' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Claim owner (damaged party): ids for the user and their insurer client scope',
|
||||
})
|
||||
owner?: {
|
||||
userId: string;
|
||||
clientId?: string;
|
||||
userClientKey?: string;
|
||||
fullName?: string;
|
||||
};
|
||||
|
||||
@@ -54,14 +73,38 @@ export class ClaimDetailsV2ResponseDto {
|
||||
nationalCodeOfOwner?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required documents status' })
|
||||
requiredDocuments?: Record<string, { uploaded: boolean; fileId?: string }>;
|
||||
@ApiPropertyOptional({ description: 'Required documents status (link instead of id)' })
|
||||
requiredDocuments?: Record<string, { uploaded: boolean; fileUrl?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Car angles captured' })
|
||||
carAngles?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
damagedParts?: Record<string, { label_fa: string; captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
|
||||
})
|
||||
expertResend?: ExpertResendDetailsV2Dto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Damage expert opinion(s): initial and final (after objection).",
|
||||
type: Object,
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||
userRating?: {
|
||||
progressSpeed: number;
|
||||
registrationEase: number;
|
||||
overallEvaluation: number;
|
||||
comment?: string;
|
||||
createdAt?: Date;
|
||||
};
|
||||
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt: string;
|
||||
|
||||
@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
|
||||
otherParts?: OtherCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
|
||||
description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
|
||||
example: 'IR123456789012345678901234',
|
||||
})
|
||||
@IsNotEmpty({ message: 'sheba is required' })
|
||||
@IsString({ message: 'sheba must be a string' })
|
||||
sheba: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy alias of sheba for backward compatibility',
|
||||
example: '123456789012345678901234',
|
||||
pattern: '^[0-9]{24}$',
|
||||
minLength: 24,
|
||||
maxLength: 24,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Sheba number is required' })
|
||||
@IsString({ message: 'Sheba number must be a string' })
|
||||
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
|
||||
@Matches(/^[0-9]{24}$/, {
|
||||
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
|
||||
})
|
||||
shebaNumber: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
shebaNumber?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'National code of the owner - 10 digits',
|
||||
description: 'National code of insurer/owner - 10 digits',
|
||||
example: '1234567890',
|
||||
pattern: '^[0-9]{10}$',
|
||||
minLength: 10,
|
||||
maxLength: 10,
|
||||
})
|
||||
@IsNotEmpty({ message: 'National code of owner is required' })
|
||||
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
||||
@IsString({ message: 'National code must be a string' })
|
||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||
@Matches(/^[0-9]{10}$/, {
|
||||
message: 'National code must contain exactly 10 digits',
|
||||
})
|
||||
nationalCodeOfOwner: string;
|
||||
nationalCodeOfInsurer: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Legacy alias for backward compatibility',
|
||||
example: '1234567890',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalCodeOfOwner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,19 +117,20 @@ export class SelectOtherPartsV2ResponseDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||
example: 'CAPTURE_PART_DAMAGES',
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Next possible workflow step',
|
||||
example: 'CAPTURE_PART_DAMAGES',
|
||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||
})
|
||||
nextStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
|
||||
example:
|
||||
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartSideV2,
|
||||
} from "src/static/outer-car-parts-catalog";
|
||||
|
||||
/**
|
||||
* Enum for valid outer car parts that can be damaged
|
||||
@@ -43,7 +47,7 @@ export class SelectOuterPartsV2Dto {
|
||||
minItems: 1,
|
||||
maxItems: 13,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Selected parts array cannot be empty' })
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedParts must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||
@@ -51,7 +55,29 @@ export class SelectOuterPartsV2Dto {
|
||||
each: true,
|
||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
||||
})
|
||||
selectedParts: OuterCarPart[];
|
||||
selectedParts?: OuterCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected outer part IDs from catalog',
|
||||
example: [9, 10, 4],
|
||||
type: [Number],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedPartIds must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
|
||||
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
|
||||
selectedPartIds?: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Vehicle type for validating available outer parts',
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: true,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +102,13 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
})
|
||||
selectedParts: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected part IDs',
|
||||
example: [9, 7, 11],
|
||||
type: [Number],
|
||||
})
|
||||
selectedPartIds: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'SELECT_OUTER_PARTS',
|
||||
@@ -94,3 +127,30 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class SetClaimVehicleTypeV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Vehicle type selected by user",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsEnum(ClaimVehicleTypeV2)
|
||||
carType: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
export class OuterPartCatalogItemDto {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
|
||||
@ApiProperty()
|
||||
titleFa: string;
|
||||
|
||||
@ApiProperty({ enum: OuterPartSideV2 })
|
||||
side: OuterPartSideV2;
|
||||
|
||||
@ApiProperty({ enum: ClaimVehicleTypeV2, required: false })
|
||||
carType?: ClaimVehicleTypeV2;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
|
||||
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
|
||||
example: 'Document uploaded successfully. 12 documents remaining.',
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
}
|
||||
|
||||
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
24
src/claim-request-management/dto/user-objection-v2.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsOptional, ValidateNested } from "class-validator";
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link import("./user-objection.dto").UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
|
||||
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
|
||||
}
|
||||
|
||||
async findOneAndUpdate(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
update: any,
|
||||
options?: { new?: boolean },
|
||||
): Promise<ClaimCaseDocument | null> {
|
||||
return this.claimCaseModel.findOneAndUpdate(filter, update, {
|
||||
new: options?.new !== false,
|
||||
});
|
||||
}
|
||||
|
||||
async find(
|
||||
filter: FilterQuery<ClaimCase>,
|
||||
options?: { lean?: boolean; select?: string },
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { CarDamagePartModel, CarDamagePartOtherModel } from "./car-parts.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class SelectedOuterPartV2 {
|
||||
@Prop({ type: Number })
|
||||
id: number;
|
||||
|
||||
@Prop({ type: String })
|
||||
key: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side: string;
|
||||
}
|
||||
export const SelectedOuterPartV2Schema =
|
||||
SchemaFactory.createForClass(SelectedOuterPartV2);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimDamageSelection {
|
||||
/**
|
||||
@@ -10,6 +24,10 @@ export class ClaimDamageSelection {
|
||||
@Prop({ type: [String], default: [] })
|
||||
selectedParts?: string[];
|
||||
|
||||
/** Structured selected outer parts with id + side for better downstream handling. */
|
||||
@Prop({ type: [SelectedOuterPartV2Schema], default: [] })
|
||||
selectedOuterParts?: SelectedOuterPartV2[];
|
||||
|
||||
/**
|
||||
* V2: Array of selected other (non-body) damaged parts
|
||||
* Examples: ['engine', 'suspension', 'headlight']
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Schema as MongooseSchema, Types } from "mongoose";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||
import { UserReplyEnum } from "src/Types&Enums/claim-request-management/userReply.enum";
|
||||
|
||||
@@ -8,8 +12,12 @@ export class ClaimPartPricing {
|
||||
@Prop({ type: String })
|
||||
partId: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
carPartDamage?: string;
|
||||
/**
|
||||
* Legacy string or structured `{ part, side }` from expert reply DTOs (V1/V2).
|
||||
* Must be Mixed — objects cannot cast to String.
|
||||
*/
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
carPartDamage?: string | { part?: string; side?: string };
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
@@ -23,8 +31,9 @@ export class ClaimPartPricing {
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
totalPayment?: string | number;
|
||||
|
||||
/** V1/V2: string/number legacy, or `{ option, price?, branchId? }` after expert normalization. */
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
daghi?: string | number;
|
||||
daghi?: string | number | Record<string, unknown>;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
factorNeeded?: boolean;
|
||||
@@ -67,6 +76,25 @@ export class ClaimUserComment {
|
||||
export const ClaimUserCommentSchema =
|
||||
SchemaFactory.createForClass(ClaimUserComment);
|
||||
|
||||
/** Owner acceptance + signature after expert pricing (WAITING_FOR_INSURER_APPROVAL @ INSURER_REVIEW). */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimOwnerInsurerApproval {
|
||||
@Prop({ type: Boolean, required: true })
|
||||
agree: boolean;
|
||||
|
||||
/** Branch the owner is signing for (must belong to their insurer; aligned with expert daghi options when present). */
|
||||
@Prop({ type: Types.ObjectId })
|
||||
branchId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
signDetailId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
signedAt?: Date;
|
||||
}
|
||||
export const ClaimOwnerInsurerApprovalSchema =
|
||||
SchemaFactory.createForClass(ClaimOwnerInsurerApproval);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimExpertReply {
|
||||
@Prop({ type: String })
|
||||
@@ -83,12 +111,17 @@ export class ClaimExpertReply {
|
||||
|
||||
@Prop({ type: ClaimUserCommentSchema })
|
||||
userComment?: ClaimUserComment;
|
||||
|
||||
/** Damage expert profile at reply time (from `damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimExpertReplySchema =
|
||||
SchemaFactory.createForClass(ClaimExpertReply);
|
||||
|
||||
/** One line item the user disputes on an expert-priced part */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjection {
|
||||
export class ClaimUserObjectionPart {
|
||||
@Prop({ type: String, required: true })
|
||||
partId: string;
|
||||
|
||||
@@ -103,9 +136,47 @@ export class ClaimUserObjection {
|
||||
|
||||
@Prop({ type: String })
|
||||
typeOfDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
carPartDamage?: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjection);
|
||||
export const ClaimUserObjectionPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPart);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionNewPart {
|
||||
@Prop({ type: String })
|
||||
partId?: string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
partName: string;
|
||||
|
||||
@Prop({ type: String })
|
||||
side?: string;
|
||||
}
|
||||
export const ClaimUserObjectionNewPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||
|
||||
/**
|
||||
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||
* Stored on {@link ClaimEvaluation.objection}.
|
||||
*/
|
||||
@Schema({ _id: false })
|
||||
export class ClaimUserObjectionPayload {
|
||||
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
|
||||
objectionParts?: ClaimUserObjectionPart[];
|
||||
|
||||
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
|
||||
newParts?: ClaimUserObjectionNewPart[];
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
submittedAt?: Date;
|
||||
}
|
||||
export const ClaimUserObjectionPayloadSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimResendRequest {
|
||||
@@ -117,6 +188,14 @@ export class ClaimResendRequest {
|
||||
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
resendCarParts?: any[];
|
||||
|
||||
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
|
||||
@Prop({ type: Date })
|
||||
fulfilledAt?: Date;
|
||||
|
||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimResendRequestSchema =
|
||||
SchemaFactory.createForClass(ClaimResendRequest);
|
||||
@@ -145,6 +224,9 @@ export class ClaimFileRating {
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
guiltyVehicleIdentification?: number;
|
||||
|
||||
@Prop({ type: Number, min: 0, max: 5 })
|
||||
botRating?: number;
|
||||
}
|
||||
export const ClaimFileRatingSchema =
|
||||
SchemaFactory.createForClass(ClaimFileRating);
|
||||
@@ -182,8 +264,8 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimResendRequestSchema })
|
||||
damageExpertResend?: ClaimResendRequest | null;
|
||||
|
||||
@Prop({ type: ClaimUserObjectionSchema })
|
||||
objection?: ClaimUserObjection;
|
||||
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||
objection?: ClaimUserObjectionPayload;
|
||||
|
||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||
userResendDocuments?: ClaimUserResendPayload;
|
||||
@@ -191,11 +273,22 @@ export class ClaimEvaluation {
|
||||
@Prop({ type: ClaimFileRatingSchema })
|
||||
rating?: ClaimFileRating;
|
||||
|
||||
@Prop({ type: ClaimOwnerInsurerApprovalSchema })
|
||||
ownerInsurerApproval?: ClaimOwnerInsurerApproval;
|
||||
|
||||
@Prop({ type: String })
|
||||
visitLocation?: string;
|
||||
|
||||
@Prop({ type: ClaimPriceDropSchema })
|
||||
priceDrop?: ClaimPriceDrop;
|
||||
|
||||
/** Damage expert profile at last factor validation (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
factorValidationExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
|
||||
/** Damage expert profile when in-person visit was requested (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
inPersonVisitExpertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimEvaluationSchema =
|
||||
SchemaFactory.createForClass(ClaimEvaluation);
|
||||
|
||||
@@ -50,6 +50,9 @@ export class ClaimOwner {
|
||||
|
||||
@Prop({ type: String })
|
||||
fullName?: string;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
clientId: Types.ObjectId;
|
||||
}
|
||||
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimActorLock {
|
||||
@@ -12,9 +17,29 @@ export class ClaimActorLock {
|
||||
|
||||
@Prop({ type: String, default: "damage_expert" })
|
||||
actorRole: "damage_expert" | "expert" | "admin";
|
||||
|
||||
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ClaimActorLockSchema = SchemaFactory.createForClass(ClaimActorLock);
|
||||
|
||||
/** Captures queue fields before a damage-expert lock; restored if the lock TTL expires without a reply. */
|
||||
@Schema({ _id: false })
|
||||
export class ClaimPreLockQueueSnapshot {
|
||||
@Prop({ type: String, enum: ClaimStatus, required: true })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: true })
|
||||
currentStep: ClaimWorkflowStep;
|
||||
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep, required: false })
|
||||
nextStep?: ClaimWorkflowStep;
|
||||
}
|
||||
export const ClaimPreLockQueueSnapshotSchema = SchemaFactory.createForClass(
|
||||
ClaimPreLockQueueSnapshot,
|
||||
);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimWorkflow {
|
||||
@Prop({ type: String, enum: ClaimWorkflowStep })
|
||||
@@ -32,8 +57,15 @@ export class ClaimWorkflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@Prop({ type: ClaimActorLockSchema })
|
||||
lockedBy?: ClaimActorLock;
|
||||
|
||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||
}
|
||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ClaimCaseSnapshotSchema,
|
||||
} from "./claim-case.snapshot.schema";
|
||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||
import { UserClaimRating } from "./claim-request-management.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequiredDocumentRef {
|
||||
@@ -75,6 +76,12 @@ export class ClaimCase {
|
||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||
claimStatus: ClaimStatus;
|
||||
|
||||
/**
|
||||
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
blameDocumentResendPending?: boolean;
|
||||
|
||||
/**
|
||||
* Link to the blame case that originated this claim.
|
||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||
@@ -133,6 +140,15 @@ export class ClaimCase {
|
||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||
history?: HistoryEvent[];
|
||||
|
||||
/**
|
||||
* User satisfaction rating (damaged party), after the case is completed.
|
||||
*/
|
||||
@Prop({ type: UserClaimRating, required: false })
|
||||
userRating?: UserClaimRating;
|
||||
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
createdByRegistrarId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* Legacy fields kept optional to simplify progressive migration.
|
||||
* If you choose to migrate later, we can remove these.
|
||||
|
||||
@@ -122,6 +122,10 @@ export class SubmitReply {
|
||||
|
||||
@Prop()
|
||||
userComment?: UserComment;
|
||||
|
||||
/** Damage expert profile at reply time (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class ActorLockDetails {
|
||||
@@ -130,6 +134,10 @@ export class ActorLockDetails {
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
actorId?: Types.ObjectId;
|
||||
|
||||
/** Damage expert profile when lock was taken (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class ResendCarPartsDto {
|
||||
@@ -149,6 +157,10 @@ export class ClaimSubmitResend {
|
||||
|
||||
@Prop({ required: true })
|
||||
resendCarParts: ResendCarPartsDto[];
|
||||
|
||||
/** Damage expert profile when resend was requested (`damage-expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class UserResendDocuments {
|
||||
@@ -360,6 +372,14 @@ export class ClaimRequestManagementModel {
|
||||
@Prop({ type: UserClaimRating, required: false })
|
||||
userRating?: UserClaimRating;
|
||||
|
||||
/** Latest damage expert profile when validating repair factors (V1). */
|
||||
@Prop({ type: Object })
|
||||
factorValidationExpertProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
/** Damage expert profile when in-person visit was requested (V1). */
|
||||
@Prop({ type: Object })
|
||||
damageExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
@Prop({ type: String, required: false })
|
||||
visitLocation?: string;
|
||||
|
||||
|
||||
135
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
135
src/claim-request-management/registrar-claim.v1.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { SelectOuterPartsV2Dto } from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto } from "./dto/select-other-parts-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
|
||||
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
|
||||
@ApiTags("registrar-claim (v1)")
|
||||
@Controller("registrar-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.REGISTRAR)
|
||||
export class RegistrarClaimV1Controller {
|
||||
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||
blameRequestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
getClaim(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.getClaimDetailsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
selectOuter(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOtherPartsV2Dto })
|
||||
selectOther(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
getCapture(@Param("claimRequestId") claimRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: "Registrar uploads required claim document" })
|
||||
uploadDoc(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}${extname(file.originalname)}`),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,27 @@ import {
|
||||
} 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 { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ClientService } from "./client.service";
|
||||
import { ClientDto } from "./dto/create-client.dto";
|
||||
|
||||
@Controller("client")
|
||||
@ApiTags("client-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiBearerAuth()
|
||||
async addClient(@Body() client: ClientDto) {
|
||||
return await this.clientService.addClient(client);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard)
|
||||
async getClient(@CurrentUser() user) {
|
||||
return await this.clientService.getClients();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
class ClientName {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
persian: string;
|
||||
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
english: string;
|
||||
}
|
||||
class Property {
|
||||
@ApiProperty({})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
smsApiKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClientDto {
|
||||
@ApiProperty({ required: true })
|
||||
@ValidateNested()
|
||||
@Type(() => ClientName)
|
||||
clientName: ClientName;
|
||||
|
||||
@ApiProperty({ required: true })
|
||||
@IsNumber()
|
||||
clientCode: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
property: Property;
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => Property)
|
||||
property?: Property;
|
||||
|
||||
@ApiProperty({ examples: ["legal", "genuine"] })
|
||||
@IsIn(["legal", "genuine"])
|
||||
useExpertMode: "legal" | "genuine";
|
||||
}
|
||||
export class ClientDtoRs {
|
||||
@@ -35,6 +48,9 @@ export class ClientDtoRs {
|
||||
useExpertsMode: string;
|
||||
constructor(readonly client) {
|
||||
this.persian = client.clientName.persian;
|
||||
this.english = client.clientName.english;
|
||||
this.clientId = client._id;
|
||||
this.useExpertsMode = client.useExpertMode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export class BranchDbService {
|
||||
async findAll(insuranceId: string): Promise<BranchModel[]> {
|
||||
return await this.branchModel.find({
|
||||
clientKey: new Types.ObjectId(insuranceId),
|
||||
});
|
||||
}, { _id: 1, name: 1, code: 1, city: 1, state: 1, address: 1, phoneNumber: 1, createdAtFa: 1, updatedAtFa: 1 });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BranchModel | null> {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
|
||||
export const CustomHeader = createParamDecorator(
|
||||
(data, ctx: ExecutionContext) => {
|
||||
console.log(ctx.switchToHttp().getRequest());
|
||||
console.log(ctx.getType());
|
||||
},
|
||||
);
|
||||
@@ -117,6 +117,7 @@ export class AllRequestDtoV2 {
|
||||
type: string;
|
||||
blameStatus: string;
|
||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||
}
|
||||
|
||||
export class AllRequestDtoRsV2 {
|
||||
|
||||
@@ -35,9 +35,14 @@ export class ResendRequestDto {
|
||||
description: "Array of resend requests (can include first party, second party, or both)",
|
||||
example: [
|
||||
{
|
||||
partyId: "507f1f77bcf86cd799439011",
|
||||
requestedItems: ["drivingLicense", "carCertificate"],
|
||||
description: "Please resend with better quality",
|
||||
partyId: "681b3b3fb237e5856429e444",
|
||||
requestedItems: ["drivingLicense"],
|
||||
description: "DESC TEST1",
|
||||
},
|
||||
{
|
||||
partyId: "68b6ba552a6e897df34e0f90",
|
||||
requestedItems: ["carCertificate"],
|
||||
description: "DESC TEST2",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ApiParam,
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response, Request } from "express";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
@@ -45,30 +46,35 @@ export class ExpertBlameController {
|
||||
|
||||
// TODO role guard for expert fix
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertBlameService.findAll(actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.findOne(id, actor.sub);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.lockRequest(id, actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("request/accident-fields")
|
||||
async getAccidentFields() {
|
||||
return await this.expertBlameService.getAccidentField();
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -95,6 +101,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/first/:id")
|
||||
@ApiBody({ type: ResendFirstPartyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -107,6 +114,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/second/:id")
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiBody({ type: ResendSecondPartyDto })
|
||||
@@ -120,6 +128,7 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
async streamVideo(@Param("requestId") requestId: string) {
|
||||
@@ -127,6 +136,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@UseInterceptors(LoggingInterceptor)
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("voice/:requestId/:voiceId")
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@@ -149,6 +159,7 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
||||
|
||||
@@ -6,9 +6,16 @@ import { ExpertBlameController } from "./expert-blame.controller";
|
||||
import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, ClientModule, RequestManagementModule, PlatesModule],
|
||||
imports: [
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
RequestManagementModule,
|
||||
PlatesModule,
|
||||
SmsOrchestrationModule,
|
||||
],
|
||||
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
||||
providers: [ExpertBlameService],
|
||||
exports: [ExpertBlameService],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import {
|
||||
Put,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiBody, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
@@ -22,9 +22,9 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
||||
@Controller("v2/expert-blame")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||
export class ExpertBlameV2Controller {
|
||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
||||
constructor(private readonly expertBlameService: ExpertBlameService) { }
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor: any) {
|
||||
@@ -38,11 +38,28 @@ export class ExpertBlameV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (tenant)",
|
||||
description:
|
||||
"IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. WAITING_FOR_EXPERT, WAITING_FOR_DOCUMENT_RESEND, WAITING_FOR_SIGNATURES, and terminal statuses are counted separately. Expert-initiated files count only for the initiating expert.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
return await this.expertBlameService.getStatusReportBucketsV2(actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to load blame status report",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||
try {
|
||||
return await this.expertBlameService.findOneV2(id, actor.sub);
|
||||
return await this.expertBlameService.findOneV2(id, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -73,7 +90,7 @@ export class ExpertBlameV2Controller {
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
|
||||
return await this.expertBlameService.resendRequestV2(id, body, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -93,7 +110,7 @@ export class ExpertBlameV2Controller {
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
|
||||
return await this.expertBlameService.replyRequestV2(id, body, actor);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
@@ -101,4 +118,22 @@ export class ExpertBlameV2Controller {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Put("reply/inPerson/:id")
|
||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
async submitInPerson(
|
||||
@Param("id") id: string,
|
||||
@Body() body: SubmitReplyDto,
|
||||
@CurrentUser() actor: any,
|
||||
) {
|
||||
try {
|
||||
return await this.expertBlameService.submitInPerson(id, actor.sub);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit expert reply for in person",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||
|
||||
export class ClaimDetailV2ResponseDto {
|
||||
@ApiProperty()
|
||||
@@ -27,6 +28,7 @@ export class ClaimDetailV2ResponseDto {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -43,6 +45,19 @@ export class ClaimDetailV2ResponseDto {
|
||||
plate?: any;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Blame file type from linked blame case',
|
||||
enum: BlameRequestType,
|
||||
})
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
|
||||
example: { car: true, object: false },
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
|
||||
blameRequestId?: string;
|
||||
|
||||
@@ -69,6 +84,62 @@ export class ClaimDetailV2ResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True when user uploaded all required factors and the case awaits expert approve/reject.',
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Expert reply payloads (first and/or final) when awaiting factor validation.',
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
damageExpertReplyFinal?: unknown;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Owner objection to expert-priced parts (`evaluation.objection`): disputed lines + optional new parts + submission time.',
|
||||
})
|
||||
objection?: {
|
||||
objectionParts?: Array<Record<string, unknown>>;
|
||||
newParts?: Array<Record<string, unknown>>;
|
||||
submittedAt?: Date | string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
|
||||
})
|
||||
videoCapture?: {
|
||||
id: string;
|
||||
url?: string;
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
|
||||
})
|
||||
blameCase?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
|
||||
})
|
||||
blameExpertDecision?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
|
||||
})
|
||||
money?: {
|
||||
sheba?: string;
|
||||
nationalCodeOfInsurer?: string;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
|
||||
|
||||
export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Claim case ID' })
|
||||
@@ -16,10 +17,12 @@ export class ClaimListItemV2Dto {
|
||||
@ApiProperty({ description: 'Whether the claim is locked by another expert', example: false })
|
||||
locked: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim' })
|
||||
@ApiPropertyOptional({ description: 'Actor who locked this claim (only while lock is active)' })
|
||||
lockedBy?: {
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
lockedAt?: string;
|
||||
expiredAt?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: 'Vehicle info from snapshot' })
|
||||
@@ -29,8 +32,28 @@ export class ClaimListItemV2Dto {
|
||||
carType?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Blame file type from linked blame case',
|
||||
enum: BlameRequestType,
|
||||
example: BlameRequestType.THIRD_PARTY,
|
||||
})
|
||||
blameRequestType?: BlameRequestType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
|
||||
example: { car: true, object: false },
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True when the claim is in the expert factor validation queue (all factors uploaded).',
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
}
|
||||
|
||||
export class GetClaimListV2ResponseDto {
|
||||
|
||||
@@ -6,8 +6,12 @@ import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
|
||||
import { DaghiOption } from 'src/Types&Enums/claim-request-management/daghi-option.enum';
|
||||
|
||||
export class CarPartDamageV2Dto {
|
||||
@ApiProperty({ example: 'left' })
|
||||
@@ -19,6 +23,31 @@ export class CarPartDamageV2Dto {
|
||||
part: string;
|
||||
}
|
||||
|
||||
/** Same shape as V1 {@link PartsList} `daghi` (damage expert reply). */
|
||||
export class DaghiDetailsV2Dto {
|
||||
@ApiProperty({
|
||||
enum: DaghiOption,
|
||||
description:
|
||||
'Daghi option: ارزش لوازم بازیافتی, تحویل داغی, فاقد ارزش, با احتساب داغی',
|
||||
})
|
||||
@IsEnum(DaghiOption)
|
||||
option: DaghiOption;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.RECYCLED_PARTS_VALUE}'`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `Required when option is '${DaghiOption.DELIVER_DAMAGED_PART}' (Mongo ObjectId string)`,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branchId?: string;
|
||||
}
|
||||
|
||||
export class PartPricingV2Dto {
|
||||
@ApiProperty({ example: 'part-001' })
|
||||
@IsString()
|
||||
@@ -46,10 +75,10 @@ export class PartPricingV2Dto {
|
||||
@IsString()
|
||||
totalPayment: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '500000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
daghi?: string;
|
||||
@ApiProperty({ type: DaghiDetailsV2Dto })
|
||||
@ValidateNested()
|
||||
@Type(() => DaghiDetailsV2Dto)
|
||||
daghi: DaghiDetailsV2Dto;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
@IsBoolean()
|
||||
@@ -68,3 +97,31 @@ export class SubmitExpertReplyV2Dto {
|
||||
@Type(() => PartPricingV2Dto)
|
||||
parts: PartPricingV2Dto[];
|
||||
}
|
||||
|
||||
export class ClaimSubmitResendV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resendDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description:
|
||||
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(ClaimRequiredDocumentType, { each: true })
|
||||
resendDocuments?: ClaimRequiredDocumentType[];
|
||||
|
||||
@ApiPropertyOptional({ type: [DamagedPartItem] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DamagedPartItem)
|
||||
resendCarParts?: DamagedPartItem[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
@@ -31,3 +31,45 @@ export class FactorValidationDto {
|
||||
@Type(() => FactorDecisionDto)
|
||||
decisions: FactorDecisionDto[];
|
||||
}
|
||||
|
||||
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
|
||||
class FactorDecisionV2Dto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
partId: string;
|
||||
|
||||
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
|
||||
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
|
||||
status: FactorStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rejectionReason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
salary?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
totalPayment?: string;
|
||||
}
|
||||
|
||||
export class FactorValidationV2Dto {
|
||||
@ApiProperty({ type: [FactorDecisionV2Dto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FactorDecisionV2Dto)
|
||||
decisions: FactorDecisionV2Dto[];
|
||||
}
|
||||
|
||||
25
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
25
src/expert-claim/dto/update-claim-damaged-parts-v2.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
} from "class-validator";
|
||||
import { OuterCarPart } from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
|
||||
export class UpdateClaimDamagedPartsV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Updated list of selected damaged outer parts",
|
||||
enum: OuterCarPart,
|
||||
isArray: true,
|
||||
example: ["hood", "front_bumper", "front_left_fender"],
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@IsEnum(OuterCarPart, { each: true })
|
||||
selectedParts: OuterCarPart[];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
@@ -40,11 +41,13 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
|
||||
export class ExpertClaimController {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiQuery({
|
||||
@@ -64,12 +67,14 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("imageRequired/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
||||
return await this.expertClaimService.imageRequired(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("required-documents/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async getRequiredDocuments(
|
||||
@@ -79,11 +84,13 @@ export class ExpertClaimController {
|
||||
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitReplyRequest(
|
||||
@@ -98,6 +105,7 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("resend/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
async submitResend(
|
||||
@@ -117,6 +125,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
@@ -134,6 +143,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("link/:id/video")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVideoLink(
|
||||
@@ -143,12 +153,14 @@ export class ExpertClaimController {
|
||||
return this.expertClaimService.getVideoLink(id, query);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/voice")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVoiceLink(@Param("id") id: string) {
|
||||
return await this.expertClaimService.getVoiceLink(id);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch("validate-factors/:claimId")
|
||||
@ApiParam({ name: "claimId" })
|
||||
@ApiBody({ type: FactorValidationDto })
|
||||
@@ -166,11 +178,13 @@ export class ExpertClaimController {
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { ExpertBlameModule } from "src/expert-blame/expert-blame.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ExpertClaimController } from "./expert-claim.controller";
|
||||
import { ExpertClaimV2Controller } from "./expert-claim.v2.controller";
|
||||
@@ -31,6 +32,7 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
ExpertBlameModule,
|
||||
AiModule,
|
||||
RequestManagementModule,
|
||||
SmsOrchestrationModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiPropertyOptional,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
@@ -14,7 +15,9 @@ import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertClaimService } from "./expert-claim.service";
|
||||
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
|
||||
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||
|
||||
class InPersonVisitV2Dto {
|
||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||
@@ -29,33 +32,40 @@ class InPersonVisitV2Dto {
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||
export class ExpertClaimV2Controller {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) { }
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count claim cases by grouped status bucket (tenant)",
|
||||
description:
|
||||
"IN_PROGRESS groups user-phase statuses before submission is complete (CREATED through CAPTURING_PART_DAMAGES). Other keys match ClaimCaseStatus. Scoped to the insurer in the JWT.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
return await this.expertClaimService.getStatusReportBucketsV2(actor);
|
||||
}
|
||||
|
||||
@Get("requests")
|
||||
@ApiOperation({
|
||||
summary: "List available claim requests for damage expert",
|
||||
description:
|
||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.",
|
||||
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
||||
})
|
||||
async getClaimListV2(@CurrentUser() actor) {
|
||||
return await this.expertClaimService.getClaimListV2(actor.sub);
|
||||
return await this.expertClaimService.getClaimListV2(actor);
|
||||
}
|
||||
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Get claim request detail for damage expert",
|
||||
description:
|
||||
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.",
|
||||
"Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async getClaimDetailV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.getClaimDetailV2(
|
||||
claimRequestId,
|
||||
actor.sub,
|
||||
);
|
||||
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
||||
}
|
||||
|
||||
@Put("lock/:claimRequestId")
|
||||
@@ -76,7 +86,7 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage assessment reply",
|
||||
description:
|
||||
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
|
||||
"Claim must be locked by this expert (status EXPERT_REVIEWING). Submitting unlocks the claim. Each part requires `daghi` with the same rules as V1 (DaghiOption, price for recycled value, branchId for deliver damaged part). If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: SubmitExpertReplyV2Dto })
|
||||
@@ -88,6 +98,23 @@ export class ExpertClaimV2Controller {
|
||||
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
|
||||
}
|
||||
|
||||
@Put("reply/resend/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit expert damage resend request",
|
||||
description:
|
||||
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
|
||||
"The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
||||
async submitExpertResendV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: ClaimSubmitResendV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
|
||||
}
|
||||
|
||||
@Patch(":claimRequestId/visit")
|
||||
@ApiOperation({
|
||||
summary: "Request in-person visit for claim",
|
||||
@@ -107,4 +134,61 @@ export class ExpertClaimV2Controller {
|
||||
body?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("validate-factors/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Validate uploaded repair factors (V2 ClaimCase)",
|
||||
description:
|
||||
"After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: FactorValidationV2Dto })
|
||||
async validateClaimFactorsV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: FactorValidationV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.validateClaimFactorsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("request/:claimRequestId/damaged-parts")
|
||||
@ApiOperation({
|
||||
summary: "Edit selected damaged parts (V2)",
|
||||
description:
|
||||
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
|
||||
async updateClaimDamagedPartsV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UpdateClaimDamagedPartsV2Dto,
|
||||
@CurrentUser() actor,
|
||||
) {
|
||||
return await this.expertClaimService.updateClaimDamagedPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiQuery({
|
||||
name: "query",
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
async claimStream(
|
||||
@Headers() headers,
|
||||
@Param("id") id: string,
|
||||
@Query("query") query: "car-capture" | "accident",
|
||||
@Res() res,
|
||||
) {
|
||||
return this.expertClaimService.streamServiceV2(id, query, res, headers);
|
||||
}
|
||||
}
|
||||
|
||||
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
83
src/expert-insurer/dto/create-insurer-expert.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
|
||||
export class CreateInsurerExpertDto {
|
||||
@ApiProperty({ example: "Ali" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ example: "Ahmadi" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: "1234567890" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nationalCode: string;
|
||||
|
||||
@ApiProperty({ example: "expert@example.com" })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: "StrongPass!123" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: "09121234567" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
mobile: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "665f0e5ab74d670939b08920",
|
||||
description: "Branch id this expert belongs to",
|
||||
})
|
||||
@IsMongoId()
|
||||
branchId: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: UserType, example: UserType.LEGAL })
|
||||
@IsOptional()
|
||||
@IsEnum(UserType)
|
||||
userType?: UserType;
|
||||
|
||||
@ApiPropertyOptional({ example: "02112345678" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "Tehran" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "No. 12, Sample St." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export class CreateBlameExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.EXPERT],
|
||||
default: RoleEnum.EXPERT,
|
||||
})
|
||||
role?: RoleEnum.EXPERT;
|
||||
}
|
||||
|
||||
export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.DAMAGE_EXPERT],
|
||||
default: RoleEnum.DAMAGE_EXPERT,
|
||||
})
|
||||
role?: RoleEnum.DAMAGE_EXPERT;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
@@ -26,6 +27,10 @@ import { FileRating } from "src/request-management/entities/schema/request-manag
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertInsurerService } from "./expert-insurer.service";
|
||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
|
||||
@Controller("expert-insurer")
|
||||
@ApiTags("expert-insurer-panel")
|
||||
@@ -35,16 +40,59 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
export class ExpertInsurerController {
|
||||
constructor(private readonly expertInsurerService: ExpertInsurerService) {}
|
||||
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
@Get("branches")
|
||||
async getInsuranceBranches(@CurrentUser() insurer) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("experts/blame")
|
||||
@ApiBody({ type: CreateBlameExpertByInsurerDto })
|
||||
async addBlameExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateBlameExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addBlameExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@Post("experts/claim")
|
||||
@ApiBody({ type: CreateClaimExpertByInsurerDto })
|
||||
async addClaimExpert(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateClaimExpertByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@ApiQuery({ name: "page", type: Number })
|
||||
@ApiQuery({ name: "response_count", type: Number })
|
||||
@Get("list")
|
||||
@Get("experts/list")
|
||||
async getAllExperts(
|
||||
@Query("page") page: number,
|
||||
@Query("response_count") count: number,
|
||||
@@ -57,43 +105,38 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("top-experts")
|
||||
@Get("experts/top")
|
||||
@ApiOperation({
|
||||
summary: "Top blame vs claim experts for this insurer",
|
||||
description:
|
||||
"Response has two arrays: `blameExperts` (roster from expert / blame files) and `claimExperts` (damage-expert roster / claim files). Each item includes `overallAverageRating` derived from ratings stored on those files plus user ratings where present.",
|
||||
})
|
||||
async getTopExperts(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getTopExpertsForClient(actor);
|
||||
}
|
||||
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
@Get("files")
|
||||
async getAllFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.retrieveAllFilesOfClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Get("/:expertId")
|
||||
async requestDetail(
|
||||
@Param("expertId") id: string,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
@Get("files/:publicId")
|
||||
@ApiParam({ name: "publicId" })
|
||||
async getFileDetailsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Param("publicId") publicId: string,
|
||||
) {
|
||||
if (!Types.ObjectId.isValid(new Types.ObjectId(id))) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
|
||||
return await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
||||
insurer.clientKey,
|
||||
publicId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
description: "Detailed expert rating",
|
||||
description:
|
||||
"One insurer rating for the shared publicId; persisted on claim and/or blame case documents when both exist.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -130,7 +173,7 @@ export class ExpertInsurerController {
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
example: 4,
|
||||
description: "برای امتیاز دادن به بات",
|
||||
description: "برای امتیاز دادن به عملکرد بات",
|
||||
},
|
||||
},
|
||||
required: [
|
||||
@@ -149,47 +192,70 @@ export class ExpertInsurerController {
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||
@Put("/:requestId/rating")
|
||||
async rateExperts(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() rating: FileRating,
|
||||
@Query("role") role: "claim" | "blame",
|
||||
) {
|
||||
if (!["claim", "blame"].includes(role)) {
|
||||
throw new BadRequestException("Invalid role");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.rateExpertOnFile(
|
||||
requestId,
|
||||
rating,
|
||||
role,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
console.log("insuranceId", insuranceId);
|
||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("branches")
|
||||
@ApiBody({ type: CreateBranchDto })
|
||||
async addBranch(
|
||||
@ApiParam({ name: "publicId" })
|
||||
@Put("files/:publicId/rating")
|
||||
async rateExpertsByPublicId(
|
||||
@CurrentUser() insurer,
|
||||
@Body() createBranchDto: CreateBranchDto,
|
||||
@Param("publicId") publicId: string,
|
||||
@Body() rating: FileRating,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
return await this.expertInsurerService.rateExpertByPublicId(
|
||||
publicId,
|
||||
rating,
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("top-files")
|
||||
async getTopFiles(@CurrentUser() insurer) {
|
||||
return await this.expertInsurerService.getTopFilesForClient(
|
||||
insurer.clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("statistics")
|
||||
async getExpertStatistics(@CurrentUser() actor) {
|
||||
return await this.expertInsurerService.getExpertStatisticsReport(actor);
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getInsurerStatusReport(
|
||||
@CurrentUser() actor,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
return await this.expertInsurerService.getInsurerFileStatusCounts(
|
||||
actor,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "expertId" })
|
||||
@ApiOperation({
|
||||
summary: "Files handled by one roster expert (summary rows)",
|
||||
description:
|
||||
"Resolves id against this insurer's `expert` then `damage-expert` roster. Blame branch runs when the id is on the expert roster (field experts). Each item is a small summary (`kind`, ids, statuses, dates)—not full file payloads. Claim matches use final or draft damage-expert reply.",
|
||||
})
|
||||
@Get("/:expertId")
|
||||
async requestDetail(@CurrentUser() insurer, @Param("expertId") id: string) {
|
||||
if (!Types.ObjectId.isValid(id)) {
|
||||
throw new BadRequestException("Invalid expert ID");
|
||||
}
|
||||
|
||||
return await this.expertInsurerService.addBranch(
|
||||
return await this.expertInsurerService.getAllFilesForInsurerExpert(
|
||||
id,
|
||||
insurer.clientKey,
|
||||
createBranchDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
BranchModel,
|
||||
BranchSchema,
|
||||
} from "src/client/entities/schema/branch.schema";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClaimRequestManagementModule,
|
||||
RequestManagementModule,
|
||||
AuthModule,
|
||||
HashModule,
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
MongooseModule.forFeature([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
export enum PanelModels {
|
||||
BLAME = "blame",
|
||||
CLAIM = "claim",
|
||||
}
|
||||
149
src/helpers/blame-party-agreement-decision.ts
Normal file
149
src/helpers/blame-party-agreement-decision.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Types } from "mongoose";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
|
||||
/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */
|
||||
export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = {
|
||||
accidentWay: { id: 8, label: "جلو به عقب" },
|
||||
accidentReason: {
|
||||
id: 1,
|
||||
fanavaran: 1,
|
||||
label:
|
||||
"عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو",
|
||||
},
|
||||
accidentType: {
|
||||
id: 1,
|
||||
label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MutualAgreementDecisionPayload = {
|
||||
guiltyPartyId: Types.ObjectId;
|
||||
description: string;
|
||||
decidedAt: Date;
|
||||
decidedByExpertId?: Types.ObjectId;
|
||||
fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS;
|
||||
};
|
||||
|
||||
function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string {
|
||||
return (p?.person?.phoneNumber || "").trim();
|
||||
}
|
||||
|
||||
function partyUserId(
|
||||
p: { person?: { userId?: Types.ObjectId } } | undefined,
|
||||
): Types.ObjectId | null {
|
||||
const id = p?.person?.userId;
|
||||
if (!id) return null;
|
||||
return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the first party chose AGREED (guilty or damaged) and the second party exists,
|
||||
* derive the guilty party and build the synthetic expert.decision payload.
|
||||
* Does not set decidedByExpertId — this is not a field-expert adjudication.
|
||||
*/
|
||||
export function buildMutualAgreementExpertDecision(
|
||||
doc: Record<string, unknown>,
|
||||
): MutualAgreementDecisionPayload | null {
|
||||
if (doc.type !== BlameRequestType.THIRD_PARTY) return null;
|
||||
if (doc.blameStatus !== BlameStatus.AGREED) return null;
|
||||
|
||||
const parties = (doc.parties ?? []) as Array<{
|
||||
role?: string;
|
||||
person?: { userId?: Types.ObjectId; phoneNumber?: string };
|
||||
statement?: {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
acceptsExpertOpinion?: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
const first = parties.find((p) => p.role === PartyRole.FIRST);
|
||||
const second = parties.find((p) => p.role === PartyRole.SECOND);
|
||||
if (!first || !second) return null;
|
||||
|
||||
const st = first.statement;
|
||||
if (!st) return null;
|
||||
if (st.acceptsExpertOpinion) return null;
|
||||
|
||||
const admits = !!st.admitsGuilt;
|
||||
const claims = !!st.claimsDamage;
|
||||
if (admits === claims) return null;
|
||||
|
||||
let guiltyParty: typeof first;
|
||||
let damagedParty: typeof first;
|
||||
if (admits) {
|
||||
guiltyParty = first;
|
||||
damagedParty = second;
|
||||
} else {
|
||||
guiltyParty = second;
|
||||
damagedParty = first;
|
||||
}
|
||||
|
||||
const guiltyId = partyUserId(guiltyParty);
|
||||
if (!guiltyId) return null;
|
||||
|
||||
const gPhone = partyPhone(guiltyParty);
|
||||
const dPhone = partyPhone(damagedParty);
|
||||
if (!gPhone || !dPhone) return null;
|
||||
|
||||
const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`;
|
||||
|
||||
return {
|
||||
guiltyPartyId: guiltyId,
|
||||
description,
|
||||
decidedAt: new Date(),
|
||||
fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge complementary statement flags onto the second party for expert/claim UIs when
|
||||
* only the first party submitted a formal confession (V2 remote flow).
|
||||
*/
|
||||
export function enrichBlamePartiesForAgreementView(
|
||||
doc: Record<string, unknown>,
|
||||
): Array<Record<string, unknown>> {
|
||||
const parties = (doc.parties ?? []) as Array<Record<string, unknown>>;
|
||||
if (!Array.isArray(parties) || parties.length === 0) return parties;
|
||||
|
||||
const first = parties.find((p) => p.role === PartyRole.FIRST) as
|
||||
| {
|
||||
statement?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND);
|
||||
if (!first?.statement || secondIdx === -1) return parties;
|
||||
|
||||
if (doc.blameStatus !== BlameStatus.AGREED) return parties;
|
||||
|
||||
const st = first.statement as {
|
||||
admitsGuilt?: boolean;
|
||||
claimsDamage?: boolean;
|
||||
acceptsExpertOpinion?: boolean;
|
||||
};
|
||||
if (st.acceptsExpertOpinion) return parties;
|
||||
|
||||
const admits = !!st.admitsGuilt;
|
||||
const claims = !!st.claimsDamage;
|
||||
if (admits === claims) return parties;
|
||||
|
||||
const second = parties[secondIdx] as {
|
||||
statement?: Record<string, unknown>;
|
||||
};
|
||||
const prev = (second.statement ?? {}) as Record<string, unknown>;
|
||||
const mergedStatement = {
|
||||
...prev,
|
||||
admitsGuilt: admits ? false : true,
|
||||
claimsDamage: admits ? true : false,
|
||||
acceptsExpertOpinion: false,
|
||||
};
|
||||
|
||||
const next = [...parties];
|
||||
next[secondIdx] = {
|
||||
...second,
|
||||
statement: mergedStatement,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
142
src/helpers/claim-car-angle-media.ts
Normal file
142
src/helpers/claim-car-angle-media.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Car-angle captures must live under {@code media.carAngles} with lowercase keys
|
||||
* {@code front | back | left | right}. Clients sometimes send Title Case keys or
|
||||
* mistakenly use {@code captureType: "part"} for angles, which previously wrote
|
||||
* into {@code media.damagedParts} and broke step-completion checks.
|
||||
*/
|
||||
|
||||
export const CLAIM_CAR_ANGLE_KEYS = ["front", "back", "left", "right"] as const;
|
||||
export type ClaimCarAngleKey = (typeof CLAIM_CAR_ANGLE_KEYS)[number];
|
||||
|
||||
export function canonicalizeClaimCarAngleKey(
|
||||
raw: string | undefined | null,
|
||||
): ClaimCarAngleKey | null {
|
||||
const t = String(raw ?? "").trim().toLowerCase();
|
||||
return (CLAIM_CAR_ANGLE_KEYS as readonly string[]).includes(t)
|
||||
? (t as ClaimCarAngleKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getCaptureBlob(data: unknown, key: string): unknown {
|
||||
if (data == null) return undefined;
|
||||
if (data instanceof Map) return (data as Map<string, unknown>).get(key);
|
||||
return (data as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
/** Prefer carAngles; fall back to legacy mis-placed blobs under damagedParts. */
|
||||
export function getClaimCarAngleCaptureBlob(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): unknown {
|
||||
return (
|
||||
getCaptureBlob(carAnglesData, angle) ??
|
||||
getCaptureBlob(damagedPartsData, angle) ??
|
||||
getCaptureBlob(
|
||||
damagedPartsData,
|
||||
angle.charAt(0).toUpperCase() + angle.slice(1),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasClaimCarAngleCapture(
|
||||
carAnglesData: unknown,
|
||||
damagedPartsData: unknown,
|
||||
angle: ClaimCarAngleKey,
|
||||
): boolean {
|
||||
return getClaimCarAngleCaptureBlob(carAnglesData, damagedPartsData, angle) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field names under media.damagedParts that should be removed when we persist the
|
||||
* same shot under media.carAngles (migration). Skips keys that are also real selected part ids.
|
||||
*/
|
||||
export function legacyAngleDamagedPartFieldKeysToUnset(
|
||||
canonical: ClaimCarAngleKey,
|
||||
rawCaptureKey: string,
|
||||
selectedParts: string[],
|
||||
): string[] {
|
||||
const selected = new Set((selectedParts ?? []).map((p) => String(p)));
|
||||
const variants = new Set(
|
||||
[
|
||||
rawCaptureKey,
|
||||
canonical,
|
||||
canonical.charAt(0).toUpperCase() + canonical.slice(1),
|
||||
].filter(Boolean),
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const k of variants) {
|
||||
if (canonicalizeClaimCarAngleKey(k) === null) continue;
|
||||
if (selected.has(k)) continue;
|
||||
out.push(k);
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
/** Same English title derived from snake_case keys as capture-requirements UI (legacy client storage). */
|
||||
export function legacyDamagedPartTitleEnFromPartKey(partKey: string): string {
|
||||
return String(partKey)
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/_/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeDamagedPartKeyLoose(s: string): string {
|
||||
return String(s).toLowerCase().replace(/[\s_-]/g, "");
|
||||
}
|
||||
|
||||
export function getDamagedPartCaptureBlob(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
): unknown {
|
||||
const pk = String(partKey ?? "").trim();
|
||||
if (!pk || damagedPartsData == null) return undefined;
|
||||
let b = getCaptureBlob(damagedPartsData, pk);
|
||||
if (b) return b;
|
||||
const titleEn = legacyDamagedPartTitleEnFromPartKey(pk);
|
||||
if (titleEn !== pk) {
|
||||
b = getCaptureBlob(damagedPartsData, titleEn);
|
||||
if (b) return b;
|
||||
}
|
||||
const target = normalizeDamagedPartKeyLoose(pk);
|
||||
const keys =
|
||||
damagedPartsData instanceof Map
|
||||
? Array.from((damagedPartsData as Map<string, unknown>).keys())
|
||||
: Object.keys(damagedPartsData as Record<string, unknown>);
|
||||
for (const k of keys) {
|
||||
if (normalizeDamagedPartKeyLoose(k) === target) {
|
||||
b = getCaptureBlob(damagedPartsData, k);
|
||||
if (b) return b;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a capture exists for catalog/selected part key {@code partKey}, including blobs stored
|
||||
* under humanized keys (e.g. {@code Left Backfender} vs {@code left_backfender}).
|
||||
*/
|
||||
export function hasDamagedPartCapture(
|
||||
damagedPartsData: unknown,
|
||||
partKey: string,
|
||||
): boolean {
|
||||
return getDamagedPartCaptureBlob(damagedPartsData, partKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a client {@code captureKey} to the canonical {@code damage.selectedParts} id when unambiguous.
|
||||
*/
|
||||
export function resolveCanonicalDamagedPartStorageKey(
|
||||
captureKeyRaw: string,
|
||||
selectedParts: string[],
|
||||
): string {
|
||||
const raw = String(captureKeyRaw ?? "").trim();
|
||||
if (!raw) return raw;
|
||||
const parts = (selectedParts ?? []).map((p) => String(p).trim()).filter(Boolean);
|
||||
if (parts.includes(raw)) return raw;
|
||||
const n = normalizeDamagedPartKeyLoose(raw);
|
||||
const matches = parts.filter((p) => normalizeDamagedPartKeyLoose(p) === n);
|
||||
if (matches.length === 1) return matches[0];
|
||||
return raw;
|
||||
}
|
||||
111
src/helpers/claim-expert-resend.ts
Normal file
111
src/helpers/claim-expert-resend.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
|
||||
|
||||
export function resendRequestHasPayload(r: {
|
||||
resendDescription?: string;
|
||||
resendDocuments?: unknown[];
|
||||
resendCarParts?: unknown[];
|
||||
}): boolean {
|
||||
if (!r) return false;
|
||||
if (String(r.resendDescription || "").trim() !== "") return true;
|
||||
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
|
||||
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
|
||||
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(docs)) return [];
|
||||
const keys: string[] = [];
|
||||
for (const d of docs) {
|
||||
if (typeof d === "string" && d.trim()) {
|
||||
const c = canonicalizeResendDocumentKey(d.trim());
|
||||
if (c) keys.push(c);
|
||||
} else if (d && typeof d === "object" && "key" in (d as object)) {
|
||||
const k = String((d as { key?: string }).key || "").trim();
|
||||
const c = canonicalizeResendDocumentKey(k);
|
||||
if (c) keys.push(c);
|
||||
}
|
||||
}
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
|
||||
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
|
||||
if (!Array.isArray(parts)) return [];
|
||||
const keys: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === "string" && p.trim()) keys.push(p.trim());
|
||||
else if (p && typeof p === "object" && "key" in (p as object)) {
|
||||
const k = String((p as { key?: string }).key || "").trim();
|
||||
if (k) keys.push(k);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
|
||||
const rd = claim?.requiredDocuments;
|
||||
if (!rd) return undefined;
|
||||
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
|
||||
}
|
||||
|
||||
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean {
|
||||
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined;
|
||||
return !!doc?.uploaded;
|
||||
}
|
||||
|
||||
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
|
||||
const data = claim?.media?.damagedParts;
|
||||
if (!data) return false;
|
||||
const cap = data instanceof Map ? data.get(partKey) : data[partKey];
|
||||
return !!cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user has satisfied every document and part the damage expert asked for,
|
||||
* or when the expert only left instructions (no doc/part list) and there is a description.
|
||||
*/
|
||||
export function canFinalizeExpertResend(claim: any): boolean {
|
||||
if (!claim) return false;
|
||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
|
||||
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false;
|
||||
|
||||
const r = claim.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
|
||||
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
|
||||
const partKeys = normalizeResendPartKeys(r.resendCarParts);
|
||||
|
||||
if (docKeys.length === 0 && partKeys.length === 0) {
|
||||
return String(r.resendDescription || "").trim() !== "";
|
||||
}
|
||||
|
||||
for (const k of docKeys) {
|
||||
if (!isRequiredDocumentUploaded(claim, k)) return false;
|
||||
}
|
||||
for (const k of partKeys) {
|
||||
if (!hasDamagedPartCapture(claim, k)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function documentKeyAllowedForExpertResend(
|
||||
claim: any,
|
||||
documentKey: string,
|
||||
): boolean {
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
|
||||
const normalized =
|
||||
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
|
||||
return allowed.has(normalized);
|
||||
}
|
||||
|
||||
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
|
||||
const r = claim?.evaluation?.damageExpertResend;
|
||||
if (!r || r.fulfilledAt) return false;
|
||||
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
|
||||
return allowed.has(partKey);
|
||||
}
|
||||
35
src/helpers/claim-resend-document-keys.ts
Normal file
35
src/helpers/claim-resend-document-keys.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
|
||||
const REQUIRED_VALUES = new Set<string>(
|
||||
Object.values(ClaimRequiredDocumentType),
|
||||
);
|
||||
|
||||
/**
|
||||
* Non-canonical document keys that may still appear on stored
|
||||
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
|
||||
*/
|
||||
const RESEND_DOCUMENT_KEY_ALIASES: Readonly<
|
||||
Record<string, ClaimRequiredDocumentType>
|
||||
> = {
|
||||
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
|
||||
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
|
||||
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
|
||||
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
|
||||
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
|
||||
};
|
||||
|
||||
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */
|
||||
export function canonicalizeResendDocumentKey(
|
||||
raw: string,
|
||||
): ClaimRequiredDocumentType | null {
|
||||
const t = String(raw || "").trim();
|
||||
if (!t) return null;
|
||||
if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType;
|
||||
return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null;
|
||||
}
|
||||
49
src/helpers/expert-panel-status-report.ts
Normal file
49
src/helpers/expert-panel-status-report.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
|
||||
/** Blame: early workflow before expert queue / resend / signatures / terminals. */
|
||||
const BLAME_IN_PROGRESS_STATUSES = new Set<string>([
|
||||
CaseStatus.OPEN,
|
||||
CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Claim: user flow before submission is complete (analog to `ClaimWorkflowStep.USER_SUBMISSION_COMPLETE`).
|
||||
*/
|
||||
const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
|
||||
ClaimCaseStatus.CREATED,
|
||||
ClaimCaseStatus.SELECTING_OUTER_PARTS,
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
]);
|
||||
|
||||
export function blameCaseStatusToReportBucket(status: string): string {
|
||||
if (BLAME_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
|
||||
return status;
|
||||
}
|
||||
|
||||
export function claimCaseStatusToReportBucket(status: string): string {
|
||||
if (CLAIM_IN_PROGRESS_STATUSES.has(status)) return "IN_PROGRESS";
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Keys returned for blame V2 expert report (IN_PROGRESS + each native post-early status). */
|
||||
export function initialBlameExpertReportBuckets(): Record<string, number> {
|
||||
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
|
||||
for (const s of Object.values(CaseStatus)) {
|
||||
if (BLAME_IN_PROGRESS_STATUSES.has(s)) continue;
|
||||
out[s] = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Keys returned for claim V2 expert report. */
|
||||
export function initialClaimExpertReportBuckets(): Record<string, number> {
|
||||
const out: Record<string, number> = { all: 0, IN_PROGRESS: 0 };
|
||||
for (const s of Object.values(ClaimCaseStatus)) {
|
||||
if (CLAIM_IN_PROGRESS_STATUSES.has(s)) continue;
|
||||
out[s] = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
29
src/helpers/expert-profile-snapshot.ts
Normal file
29
src/helpers/expert-profile-snapshot.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||
import { DamageExpertModel } from "src/users/entities/schema/damage-expert.schema";
|
||||
import { ExpertProfileSnapshot } from "src/request-management/entities/schema/expert-profile-snapshot.schema";
|
||||
|
||||
export function snapshotFromFieldExpert(
|
||||
doc: ExpertModel | null | undefined,
|
||||
): ExpertProfileSnapshot | undefined {
|
||||
if (!doc) return undefined;
|
||||
return {
|
||||
firstName: doc.firstName ?? undefined,
|
||||
lastName: doc.lastName ?? undefined,
|
||||
email: doc.email ?? undefined,
|
||||
insuActivityCo: doc.insuActivityCo ?? undefined,
|
||||
mobile: (doc.mobile ?? doc.phone) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotFromDamageExpert(
|
||||
doc: DamageExpertModel | null | undefined,
|
||||
): ExpertProfileSnapshot | undefined {
|
||||
if (!doc) return undefined;
|
||||
return {
|
||||
firstName: doc.firstName ?? undefined,
|
||||
lastName: doc.lastName ?? undefined,
|
||||
email: doc.email ?? undefined,
|
||||
insuActivityCo: doc.insuActivityCo ?? undefined,
|
||||
mobile: doc.mobile ?? undefined,
|
||||
};
|
||||
}
|
||||
115
src/helpers/mongoose-fa-timestamps.plugin.ts
Normal file
115
src/helpers/mongoose-fa-timestamps.plugin.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Connection, Query, Schema } from "mongoose";
|
||||
|
||||
const PERSIAN_TO_ENGLISH: Record<string, string> = {
|
||||
"۰": "0",
|
||||
"۱": "1",
|
||||
"۲": "2",
|
||||
"۳": "3",
|
||||
"۴": "4",
|
||||
"۵": "5",
|
||||
"۶": "6",
|
||||
"۷": "7",
|
||||
"۸": "8",
|
||||
"۹": "9",
|
||||
};
|
||||
|
||||
function normalizeDigits(value: string): string {
|
||||
return value.replace(/[۰-۹]/g, (ch) => PERSIAN_TO_ENGLISH[ch] ?? ch);
|
||||
}
|
||||
|
||||
function toIranFaDateTimeTuple(input: Date): [string, string] {
|
||||
const dateFa = normalizeDigits(
|
||||
input.toLocaleDateString("fa-IR", {
|
||||
timeZone: "Asia/Tehran",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}),
|
||||
);
|
||||
const timeFa = normalizeDigits(
|
||||
input.toLocaleTimeString("fa-IR", {
|
||||
timeZone: "Asia/Tehran",
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
);
|
||||
return [dateFa, timeFa];
|
||||
}
|
||||
|
||||
function isMissingFaTuple(value: unknown): boolean {
|
||||
if (value == null) return true;
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (value.length < 2) return true;
|
||||
const [d, t] = value;
|
||||
return !d || !t;
|
||||
}
|
||||
|
||||
function writeFaTimestampsOnUpdate(query: Query<any, any>): void {
|
||||
const nowFa = toIranFaDateTimeTuple(new Date());
|
||||
const update = (query.getUpdate() ?? {}) as Record<string, any>;
|
||||
|
||||
if (!update.$set || typeof update.$set !== "object") {
|
||||
update.$set = {};
|
||||
}
|
||||
|
||||
update.$set.updatedAtFa = nowFa;
|
||||
|
||||
// Keep createdAtFa immutable on regular updates; set only for upsert inserts.
|
||||
const opts = (query as any).getOptions?.() ?? {};
|
||||
if (opts.upsert) {
|
||||
if (!update.$setOnInsert || typeof update.$setOnInsert !== "object") {
|
||||
update.$setOnInsert = {};
|
||||
}
|
||||
if (isMissingFaTuple(update.$setOnInsert.createdAtFa)) {
|
||||
update.$setOnInsert.createdAtFa = nowFa;
|
||||
}
|
||||
}
|
||||
|
||||
query.setUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `createdAtFa` / `updatedAtFa` (Iran timezone tuple: [date, time])
|
||||
* to all schemas that use mongoose timestamps.
|
||||
*/
|
||||
export function applyIranFaTimestampPlugin(connection: Connection): void {
|
||||
connection.plugin((schema: Schema) => {
|
||||
const timestamps = schema.get("timestamps");
|
||||
if (!timestamps) return;
|
||||
|
||||
schema.add({
|
||||
createdAtFa: { type: [String], index: true },
|
||||
updatedAtFa: { type: [String], index: true },
|
||||
});
|
||||
|
||||
schema.pre("save", function (next) {
|
||||
const nowFa = toIranFaDateTimeTuple(new Date());
|
||||
if (isMissingFaTuple((this as any).createdAtFa)) {
|
||||
(this as any).createdAtFa = nowFa;
|
||||
}
|
||||
(this as any).updatedAtFa = nowFa;
|
||||
next();
|
||||
});
|
||||
|
||||
schema.pre("insertMany", function (next, docs: any[]) {
|
||||
const nowFa = toIranFaDateTimeTuple(new Date());
|
||||
for (const doc of docs) {
|
||||
if (isMissingFaTuple(doc.createdAtFa)) doc.createdAtFa = nowFa;
|
||||
doc.updatedAtFa = nowFa;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
schema.pre("findOneAndUpdate", function () {
|
||||
writeFaTimestampsOnUpdate(this);
|
||||
});
|
||||
schema.pre("updateOne", function () {
|
||||
writeFaTimestampsOnUpdate(this);
|
||||
});
|
||||
schema.pre("updateMany", function () {
|
||||
writeFaTimestampsOnUpdate(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
71
src/helpers/tenant-scope.ts
Normal file
71
src/helpers/tenant-scope.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
|
||||
export function requireActorClientKey(actor: { clientKey?: string }): string {
|
||||
const ck = actor?.clientKey;
|
||||
if (!ck || typeof ck !== "string") {
|
||||
throw new BadRequestException(
|
||||
"Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.",
|
||||
);
|
||||
}
|
||||
return ck;
|
||||
}
|
||||
|
||||
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
|
||||
const id = String(clientKey);
|
||||
return (doc?.parties ?? []).some(
|
||||
(p: any) => String(p?.person?.clientId ?? "") === id,
|
||||
);
|
||||
}
|
||||
|
||||
export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
|
||||
const id = String(clientKey);
|
||||
const owner = doc?.owner as
|
||||
| { clientId?: unknown; userClientKey?: unknown }
|
||||
| undefined;
|
||||
const tenantId = owner?.clientId ?? owner?.userClientKey;
|
||||
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(
|
||||
doc: any,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
): boolean {
|
||||
if (
|
||||
doc.expertInitiated &&
|
||||
doc.initiatedByFieldExpertId &&
|
||||
String(doc.initiatedByFieldExpertId) === actor.sub
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const ck = actor?.clientKey;
|
||||
if (!ck) return false;
|
||||
return blameCaseTouchesClient(doc, ck);
|
||||
}
|
||||
|
||||
export function assertBlameCaseForExpertTenant(
|
||||
doc: any,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
): void {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||
throw new ForbiddenException(
|
||||
"This blame case does not belong to your organization.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertClaimCaseForTenant(
|
||||
doc: any,
|
||||
actor: { clientKey?: string },
|
||||
): void {
|
||||
const ck = requireActorClientKey(actor);
|
||||
if (!claimCaseTouchesClient(doc, ck)) {
|
||||
throw new ForbiddenException(
|
||||
"This claim does not belong to your organization.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,401 +1,45 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import axios from "axios";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class LookupsService {
|
||||
private readonly logger = new Logger(LookupsService.name);
|
||||
private readonly GET_APP_TOKEN_URL =
|
||||
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
|
||||
private readonly LOGIN_URL =
|
||||
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
|
||||
private readonly BASE_API_URL =
|
||||
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
|
||||
private readonly BASE_INFO_PATH = "base-info";
|
||||
private readonly CODE_LIST_PATH = "code-list";
|
||||
|
||||
// Authentication credentials
|
||||
private readonly APP_NAME = "fanhab";
|
||||
private readonly SECRET = "5Fa@N#A2B";
|
||||
private readonly USERNAME = "fanhabUser";
|
||||
private readonly PASSWORD = "Fan#@2U$3er";
|
||||
|
||||
// API headers
|
||||
private readonly CORP_ID = "3539";
|
||||
private readonly CONTRACT_ID = "263";
|
||||
private readonly LOCATION = "100";
|
||||
|
||||
constructor(
|
||||
private readonly lookupDbService: LookupDbService,
|
||||
) {}
|
||||
constructor(private readonly lookupDbService: LookupDbService) {}
|
||||
|
||||
/**
|
||||
* Step 1: Get appToken from GetAppToken API
|
||||
* The token is returned in the response header 'appToken'
|
||||
* Returns stored `response` for a lookup document by `name` in the lookups collection.
|
||||
*/
|
||||
private async getAppToken(): Promise<string> {
|
||||
try {
|
||||
const requestHeaders: any = {
|
||||
appname: this.APP_NAME,
|
||||
secret: this.SECRET,
|
||||
"Content-Length": "0",
|
||||
};
|
||||
// Remove Content-Type header when Content-Length is 0
|
||||
delete requestHeaders["Content-Type"];
|
||||
|
||||
const response = await axios({
|
||||
method: "POST",
|
||||
url: this.GET_APP_TOKEN_URL,
|
||||
data: "", // Empty string body
|
||||
headers: requestHeaders,
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
// Remove Content-Type header completely for empty body
|
||||
if (headers) {
|
||||
delete headers["Content-Type"];
|
||||
delete headers["content-type"];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Axios lowercases headers, so check both cases
|
||||
const appToken =
|
||||
response.headers.apptoken ||
|
||||
response.headers.appToken ||
|
||||
response.headers["apptoken"] ||
|
||||
response.headers["appToken"];
|
||||
if (!appToken) {
|
||||
this.logger.error("Failed to get appToken from response headers");
|
||||
this.logger.error("Response status:", response.status);
|
||||
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
|
||||
this.logger.error("Available header keys:", Object.keys(response.headers));
|
||||
this.logger.error("Response data:", response.data);
|
||||
|
||||
// Extract error message from response if available
|
||||
const errorMessage = response.data?.Message ||
|
||||
response.data?.message ||
|
||||
response.data ||
|
||||
"Failed to get appToken from response headers";
|
||||
|
||||
throw new BadGatewayException(errorMessage);
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully obtained appToken: ${appToken}`);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get appToken");
|
||||
if (axios.isAxiosError(error)) {
|
||||
this.logger.error("Error status:", error.response?.status);
|
||||
this.logger.error("Error status text:", error.response?.statusText);
|
||||
this.logger.error("Error response data:", error.response?.data);
|
||||
this.logger.error("Error response headers:", error.response?.headers);
|
||||
this.logger.error("Request URL:", error.config?.url);
|
||||
this.logger.error("Request headers:", error.config?.headers);
|
||||
|
||||
// Extract error message from response
|
||||
const errorMessage = error.response?.data?.Message ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data ||
|
||||
error.message ||
|
||||
"Failed to get appToken from external API";
|
||||
|
||||
throw new BadGatewayException(errorMessage);
|
||||
} else {
|
||||
this.logger.error("Error details:", error);
|
||||
throw new BadGatewayException("Failed to get appToken from external API");
|
||||
}
|
||||
async getLookup(lookupName: string): Promise<any> {
|
||||
const doc = await this.lookupDbService.findOne({ name: lookupName });
|
||||
if (!doc) {
|
||||
this.logger.warn(`Lookup not found in database: ${lookupName}`);
|
||||
throw new NotFoundException(`Lookup "${lookupName}" not found`);
|
||||
}
|
||||
return doc.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Login to get authenticationToken
|
||||
* The token is returned in the response body field 'authenticationtoken'
|
||||
*/
|
||||
private async login(appToken: string): Promise<string> {
|
||||
try {
|
||||
this.logger.log(`Attempting login with appToken: ${appToken}`);
|
||||
this.logger.log(`Login URL: ${this.LOGIN_URL}`);
|
||||
this.logger.log(`Username: ${this.USERNAME}`);
|
||||
|
||||
const requestHeaders: any = {
|
||||
appToken: appToken,
|
||||
userName: this.USERNAME,
|
||||
password: this.PASSWORD,
|
||||
"Content-Length": "0",
|
||||
};
|
||||
// Remove Content-Type header when Content-Length is 0
|
||||
// Axios adds it automatically, but server doesn't expect it for empty body
|
||||
delete requestHeaders["Content-Type"];
|
||||
|
||||
this.logger.log("Request headers (without password):", {
|
||||
appToken: appToken,
|
||||
userName: this.USERNAME,
|
||||
password: "***",
|
||||
"Content-Length": "0",
|
||||
"Content-Type": "removed",
|
||||
});
|
||||
|
||||
const response = await axios({
|
||||
method: "POST",
|
||||
url: this.LOGIN_URL,
|
||||
data: "", // Empty string body
|
||||
headers: requestHeaders,
|
||||
transformRequest: [
|
||||
(data, headers) => {
|
||||
// Remove Content-Type header completely for empty body
|
||||
if (headers) {
|
||||
delete headers["Content-Type"];
|
||||
delete headers["content-type"];
|
||||
}
|
||||
return data;
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
this.logger.log("Login response status:", response.status);
|
||||
this.logger.log("Login response data:", JSON.stringify(response.data, null, 2));
|
||||
this.logger.log("Login response headers:", JSON.stringify(response.headers, null, 2));
|
||||
|
||||
// Check response headers first (authenticationToken is in headers, not body)
|
||||
const authenticationToken =
|
||||
response.headers.authenticationtoken ||
|
||||
response.headers.authenticationToken ||
|
||||
response.headers["authenticationtoken"] ||
|
||||
response.headers["authenticationToken"] ||
|
||||
// Fallback to body if not in headers
|
||||
response.data?.authenticationtoken ||
|
||||
response.data?.authenticationToken ||
|
||||
response.data?.authentication_token;
|
||||
|
||||
this.logger.log(`Extracted authenticationToken: ${authenticationToken ? 'Found' : 'Not found'}`);
|
||||
|
||||
if (!authenticationToken) {
|
||||
this.logger.error("Failed to get authenticationToken from response");
|
||||
this.logger.error("Response status:", response.status);
|
||||
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
|
||||
this.logger.error("Response headers keys:", Object.keys(response.headers || {}));
|
||||
this.logger.error("Response data:", JSON.stringify(response.data, null, 2));
|
||||
this.logger.error("Response data keys:", Object.keys(response.data || {}));
|
||||
|
||||
// Extract error message from response if available
|
||||
const errorMessage = response.data?.Message ||
|
||||
response.data?.message ||
|
||||
"Failed to get authenticationToken from response";
|
||||
|
||||
throw new BadGatewayException(errorMessage);
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully obtained authenticationToken: ${authenticationToken}`);
|
||||
return authenticationToken;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to login");
|
||||
this.logger.error("AppToken used:", appToken);
|
||||
this.logger.error("Login URL:", this.LOGIN_URL);
|
||||
this.logger.error("Username:", this.USERNAME);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
this.logger.error("Error status:", error.response?.status);
|
||||
this.logger.error("Error status text:", error.response?.statusText);
|
||||
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
|
||||
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
|
||||
this.logger.error("Request URL:", error.config?.url);
|
||||
this.logger.error("Request method:", error.config?.method);
|
||||
this.logger.error("Request headers:", JSON.stringify({
|
||||
...error.config?.headers,
|
||||
password: "***",
|
||||
}, null, 2));
|
||||
|
||||
// Extract error message from response
|
||||
const errorMessage = error.response?.data?.Message ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data ||
|
||||
error.message ||
|
||||
"Failed to login to external API";
|
||||
|
||||
throw new BadGatewayException(errorMessage);
|
||||
} else {
|
||||
this.logger.error("Error details:", error);
|
||||
throw new BadGatewayException("Failed to login to external API");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Call the lookup API with authenticationToken
|
||||
*/
|
||||
private async callLookupApi(
|
||||
lookupName: string,
|
||||
authenticationToken: string,
|
||||
pathType: "base-info" | "code-list" = "base-info",
|
||||
): Promise<any> {
|
||||
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
authenticationToken: authenticationToken,
|
||||
CorpId: this.CORP_ID,
|
||||
ContractId: this.CONTRACT_ID,
|
||||
Location: this.LOCATION,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully fetched data for lookup: ${lookupName}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch lookup data for ${lookupName}`);
|
||||
this.logger.error("URL:", url);
|
||||
this.logger.error("AuthenticationToken used:", authenticationToken);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
this.logger.error("Error status:", error.response?.status);
|
||||
this.logger.error("Error status text:", error.response?.statusText);
|
||||
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
|
||||
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
|
||||
this.logger.error("Request URL:", error.config?.url);
|
||||
this.logger.error("Request headers:", JSON.stringify(error.config?.headers, null, 2));
|
||||
// Extract error message from response
|
||||
const errorMessage = error.response?.data?.Message ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data ||
|
||||
error.message ||
|
||||
`Failed to fetch lookup data for ${lookupName}`;
|
||||
|
||||
throw new BadGatewayException(errorMessage);
|
||||
} else {
|
||||
this.logger.error("Error details:", error);
|
||||
throw new BadGatewayException(
|
||||
`Failed to fetch lookup data for ${lookupName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete authentication flow and fetch lookup data
|
||||
*/
|
||||
private async fetchLookupDataFromExternalApi(
|
||||
lookupName: string,
|
||||
pathType: "base-info" | "code-list" = "base-info",
|
||||
): Promise<{ authenticationToken: string; response: any; url: string }> {
|
||||
// Step 1: Get appToken
|
||||
const appToken = await this.getAppToken();
|
||||
|
||||
// Step 2: Login to get authenticationToken
|
||||
const authenticationToken = await this.login(appToken);
|
||||
|
||||
// Step 3: Call the lookup API
|
||||
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
|
||||
const response = await this.callLookupApi(
|
||||
lookupName,
|
||||
authenticationToken,
|
||||
pathType,
|
||||
);
|
||||
|
||||
return { authenticationToken, response, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if lookup data needs to be updated (once a month)
|
||||
*/
|
||||
private shouldUpdateLookup(updatedAt: Date | null): boolean {
|
||||
if (!updatedAt) {
|
||||
return true; // Never updated, need to fetch
|
||||
}
|
||||
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
return updatedAt < oneMonthAgo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lookup data by name
|
||||
* Returns cached data if available and not expired, otherwise fetches from external API
|
||||
*/
|
||||
async getLookup(
|
||||
lookupName: string,
|
||||
pathType: "base-info" | "code-list" = "base-info",
|
||||
): Promise<any> {
|
||||
// Check if we have cached data
|
||||
const existingLookup = await this.lookupDbService.findOne({
|
||||
name: lookupName,
|
||||
});
|
||||
|
||||
if (existingLookup && !this.shouldUpdateLookup(existingLookup.updatedAt)) {
|
||||
this.logger.log(
|
||||
`Returning cached data for lookup: ${lookupName} (last updated: ${existingLookup.updatedAt})`,
|
||||
);
|
||||
return existingLookup.response;
|
||||
}
|
||||
|
||||
// Need to fetch from external API
|
||||
this.logger.log(`Fetching fresh data for lookup: ${lookupName}`);
|
||||
const { authenticationToken, response, url } =
|
||||
await this.fetchLookupDataFromExternalApi(lookupName, pathType);
|
||||
|
||||
// Save or update in database
|
||||
await this.lookupDbService.findOneAndUpdate(
|
||||
{ name: lookupName },
|
||||
{
|
||||
url: url,
|
||||
authenticationToken: authenticationToken,
|
||||
name: lookupName,
|
||||
response: response,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully saved/updated lookup data for: ${lookupName}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accident-causes lookup data
|
||||
*/
|
||||
async getAccidentCauses(): Promise<any> {
|
||||
return await this.getLookup("accident-causes", "base-info");
|
||||
return await this.getLookup("accident-causes");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accident-report-type lookup data
|
||||
*/
|
||||
async getAccidentReportType(): Promise<any> {
|
||||
return await this.getLookup("accident-report-type", "code-list");
|
||||
return await this.getLookup("accident-report-type");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vehicle-use-types lookup data
|
||||
*/
|
||||
async getVehicleUseTypes(): Promise<any> {
|
||||
return await this.getLookup("vehicle-use-types", "base-info");
|
||||
return await this.getLookup("vehicle-use-types");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dmg-pay-method lookup data
|
||||
*/
|
||||
async getDmgPayMethod(): Promise<any> {
|
||||
return await this.getLookup("dmg-pay-method", "code-list");
|
||||
return await this.getLookup("dmg-pay-method");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driving-licence-types lookup data
|
||||
*/
|
||||
async getDrivingLicenceTypes(): Promise<any> {
|
||||
return await this.getLookup("driving-licence-types", "base-info");
|
||||
return await this.getLookup("driving-licence-types");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accident-culprit-type lookup data
|
||||
*/
|
||||
async getAccidentCulpritType(): Promise<any> {
|
||||
return await this.getLookup("accident-culprit-type", "code-list");
|
||||
return await this.getLookup("accident-culprit-type");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
src/main.ts
18
src/main.ts
@@ -32,13 +32,27 @@ async function bootstrap() {
|
||||
.setVersion("1.0.0")
|
||||
.addServer(process.env.URL)
|
||||
.addServer(process.env.BASE_URL + "/api")
|
||||
.addServer("http://192.168.20.249:9001")
|
||||
.addServer("http://192.168.20.170:9001")
|
||||
.addServer("http://localhost:9001")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const docs = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("/docs", app, docs);
|
||||
SwaggerModule.setup("/docs", app, docs, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
docExpansion: "none",
|
||||
tagsSorter: (a: string, b: string) => {
|
||||
const priority: Record<string, number> = {
|
||||
user: 0,
|
||||
actor: 1,
|
||||
};
|
||||
const pa = priority[a] ?? 100;
|
||||
const pb = priority[b] ?? 100;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return a.localeCompare(b);
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.listen(process.env.PORT);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,6 @@ export class ProfileController {
|
||||
@UseGuards(GlobalGuard)
|
||||
@ApiBody({ type: AddPlateProfileDto })
|
||||
async addPlate(@CurrentUser() user, @Body() body: AddPlateDto) {
|
||||
console.log(
|
||||
"🚀 ~ file: profile.controller.ts:50 ~ ProfileController ~ addPlate ~ user:",
|
||||
user,
|
||||
);
|
||||
return await this.plateService.addPlate(user.sub, body);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,99 @@
|
||||
export class DamageExpertAllRequestsCountReportDtoRs {
|
||||
all: number;
|
||||
UnChecked: number;
|
||||
CheckedRequest: number;
|
||||
CheckAgain: number;
|
||||
WaitingForUserToResend: number;
|
||||
CloseRequest: number;
|
||||
WaitingForUserCompleted: number;
|
||||
InPersonVisit: number;
|
||||
/**
|
||||
* V2-style status buckets (`IN_PROGRESS` + native statuses) for insurer tenant scope.
|
||||
* `unifiedByPublicId.total` matches merging blame + claim by `publicId` (see insurer `files` API).
|
||||
*/
|
||||
export class InsurerRequestsSummaryDtoRs {
|
||||
claim: Record<string, number>;
|
||||
blame: Record<string, number>;
|
||||
unifiedByPublicId: { total: number };
|
||||
|
||||
constructor(report: any) {
|
||||
this.all = report.all;
|
||||
this.UnChecked = report.UnChecked;
|
||||
this.CheckedRequest = report.CheckedRequest;
|
||||
this.CheckAgain = report.CheckAgain;
|
||||
this.WaitingForUserToResend = report.WaitingForUserToResend;
|
||||
this.CloseRequest = report.CloseRequest;
|
||||
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
|
||||
this.InPersonVisit = report.InPersonVisit;
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpertAllRequestsCountReportDtoRs {
|
||||
all: number;
|
||||
UnChecked: number;
|
||||
CheckedRequest: number;
|
||||
CheckAgain: number;
|
||||
WaitingForUserToResend: number;
|
||||
CloseRequest: number;
|
||||
WaitingForUserCompleted: number;
|
||||
|
||||
constructor(report: any) {
|
||||
this.all = report.all;
|
||||
this.UnChecked = report.UnChecked;
|
||||
this.CheckedRequest = report.CheckedRequest;
|
||||
this.CheckAgain = report.CheckAgain;
|
||||
this.WaitingForUserToResend = report.WaitingForUserToResend;
|
||||
this.CloseRequest = report.CloseRequest;
|
||||
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
export class CompanyAllRequestsCountReportDtoRs {
|
||||
blame: object;
|
||||
claim: object;
|
||||
|
||||
constructor(blame: object, claim: object) {
|
||||
this.blame = blame;
|
||||
constructor(
|
||||
claim: Record<string, number>,
|
||||
blame: Record<string, number>,
|
||||
unifiedTotal: number,
|
||||
) {
|
||||
this.claim = claim;
|
||||
this.blame = blame;
|
||||
this.unifiedByPublicId = { total: unifiedTotal };
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerPerMonthReportRowDtoRs {
|
||||
stDate: Date;
|
||||
enDate: Date;
|
||||
faLabel: string;
|
||||
data: InsurerRequestsSummaryDtoRs;
|
||||
|
||||
constructor(
|
||||
stDate: Date,
|
||||
enDate: Date,
|
||||
faLabel: string,
|
||||
data: InsurerRequestsSummaryDtoRs,
|
||||
) {
|
||||
this.stDate = stDate;
|
||||
this.enDate = enDate;
|
||||
this.faLabel = faLabel;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */
|
||||
export class InsurerExpertWorkLogEntryDtoRs {
|
||||
expertId: string;
|
||||
fullName: string;
|
||||
/**
|
||||
* `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`).
|
||||
* `damage_expert` = claim damage expert (`damage-expert` collection).
|
||||
* Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here.
|
||||
*/
|
||||
expertKind: "expert" | "damage_expert";
|
||||
/** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */
|
||||
totalHandled: number;
|
||||
/** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */
|
||||
currentlyChecking: number;
|
||||
/** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */
|
||||
distinctFilesCheckedInPeriod: number;
|
||||
|
||||
constructor(
|
||||
expertId: string,
|
||||
fullName: string,
|
||||
expertKind: "expert" | "damage_expert",
|
||||
totalHandled: number,
|
||||
currentlyChecking: number,
|
||||
distinctFilesCheckedInPeriod: number,
|
||||
) {
|
||||
this.expertId = expertId;
|
||||
this.fullName = fullName;
|
||||
this.expertKind = expertKind;
|
||||
this.totalHandled = totalHandled;
|
||||
this.currentlyChecking = currentlyChecking;
|
||||
this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerExpertWorkLogResponseDtoRs {
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[];
|
||||
|
||||
constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) {
|
||||
this.experts = experts;
|
||||
}
|
||||
}
|
||||
|
||||
export class InsurerExpertWorkLogPerMonthRowDtoRs {
|
||||
stDate: Date;
|
||||
enDate: Date;
|
||||
faLabel: string;
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[];
|
||||
|
||||
constructor(
|
||||
stDate: Date,
|
||||
enDate: Date,
|
||||
faLabel: string,
|
||||
experts: InsurerExpertWorkLogEntryDtoRs[],
|
||||
) {
|
||||
this.stDate = stDate;
|
||||
this.enDate = enDate;
|
||||
this.faLabel = faLabel;
|
||||
this.experts = experts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { ClientKey } from "src/decorators/clientKey.decorator";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
@@ -11,29 +15,76 @@ import { ReportsService } from "./reports.service";
|
||||
@ApiTags("reports")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.EXPERT, RoleEnum.COMPANY)
|
||||
@Roles(RoleEnum.COMPANY)
|
||||
@Controller("reports")
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@Get("/report/requests")
|
||||
async getAllRequestsReport(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.reportsService.getAllRequestsReportCount(actor, client);
|
||||
@Get("report/insurer/requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: claim/blame status buckets + unified file count",
|
||||
description:
|
||||
"Claim and blame counts use the same V2 bucket rules as expert panels (IN_PROGRESS grouping), scoped to the insurer JWT tenant. unifiedByPublicId.total counts distinct publicId values like GET expert-insurer/files.",
|
||||
})
|
||||
async getInsurerRequestsSummary(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerRequestsSummary(actor);
|
||||
}
|
||||
|
||||
@Get("/report/perMonthRequests")
|
||||
async getAllRequestsReportPerMonth(
|
||||
@Get("report/insurer/per-month-requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: same summary as report/insurer/requests, per calendar month",
|
||||
description:
|
||||
"Last five calendar months; each slice filters blame/claim rows by createdAt in that month, then claim buckets, blame buckets, and unified publicId count.",
|
||||
})
|
||||
async getInsurerRequestsSummaryPerMonth(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerRequestsSummaryPerMonth(actor);
|
||||
}
|
||||
|
||||
@Get("report/insurer/checked-requests")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: summary filtered by optional createdAt range",
|
||||
description:
|
||||
"Same payload as report/insurer/requests. When from/to are omitted, all tenant files are included. When provided, only blame/claim rows whose createdAt falls in the range are counted (inclusive).",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "from",
|
||||
required: false,
|
||||
description: "Optional start datetime (ISO string)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "to",
|
||||
required: false,
|
||||
description: "Optional end datetime (ISO string)",
|
||||
})
|
||||
async getInsurerRequestsSummaryByDateRange(
|
||||
@CurrentUser() actor,
|
||||
@ClientKey() client,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
) {
|
||||
return await this.reportsService.getAllRequestsReportPerMonth(
|
||||
return await this.reportsService.getInsurerRequestsSummaryByDateRange(
|
||||
actor,
|
||||
client,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("/report/checkedRequests")
|
||||
async getAllCheckedRequestsCount(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.reportsService.getAllCheckedRequestsCount(actor, client);
|
||||
@Get("report/insurer/expert-work-log")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: per-expert work log (blame experts + damage experts)",
|
||||
description:
|
||||
"Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.",
|
||||
})
|
||||
async getInsurerExpertWorkLog(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerExpertWorkLog(actor);
|
||||
}
|
||||
|
||||
@Get("report/insurer/expert-work-log/per-month")
|
||||
@ApiOperation({
|
||||
summary: "Insurer: blame + damage expert work log, last five calendar months",
|
||||
description:
|
||||
"Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that month’s end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).",
|
||||
})
|
||||
async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) {
|
||||
return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { ClientModule } from "src/client/client.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { ReportsController } from "./reports.controller";
|
||||
import { ReportsService } from "./reports.service";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ReportsService } from "./reports.service";
|
||||
imports: [
|
||||
RequestManagementModule,
|
||||
ClaimRequestManagementModule,
|
||||
ClientModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
|
||||
@@ -1,413 +1,496 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
||||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import {
|
||||
CompanyAllRequestsCountReportDtoRs,
|
||||
DamageExpertAllRequestsCountReportDtoRs,
|
||||
ExpertAllRequestsCountReportDtoRs,
|
||||
blameCaseStatusToReportBucket,
|
||||
claimCaseStatusToReportBucket,
|
||||
initialBlameExpertReportBuckets,
|
||||
initialClaimExpertReportBuckets,
|
||||
} from "src/helpers/expert-panel-status-report";
|
||||
import {
|
||||
blameCaseTouchesClient,
|
||||
claimCaseTouchesClient,
|
||||
requireActorClientKey,
|
||||
} from "src/helpers/tenant-scope";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import {
|
||||
InsurerExpertWorkLogPerMonthRowDtoRs,
|
||||
InsurerExpertWorkLogResponseDtoRs,
|
||||
InsurerExpertWorkLogEntryDtoRs,
|
||||
InsurerPerMonthReportRowDtoRs,
|
||||
InsurerRequestsSummaryDtoRs,
|
||||
} from "./dto/reports.dto";
|
||||
|
||||
type NormalizedActivity = {
|
||||
expertId: string;
|
||||
fileId: string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly requestManagementDbService: RequestManagementDbService,
|
||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
) {}
|
||||
|
||||
async getAllCheckedRequestsCountFn(role: string, client: string) {
|
||||
const statuses = ["UnChecked", "CheckedRequest"];
|
||||
const data: Record<string, number> = { all: 0 };
|
||||
const clientId = new Types.ObjectId(client);
|
||||
private parseDateRange(from?: string, to?: string) {
|
||||
const fromDate = from ? new Date(from) : undefined;
|
||||
const toDate = to ? new Date(to) : undefined;
|
||||
|
||||
for (const status of statuses) {
|
||||
let count = 0;
|
||||
if (role === "damage_expert") {
|
||||
count = await this.claimRequestManagementDbService.countByFilter({
|
||||
claimStatus: status,
|
||||
userClientKey: clientId,
|
||||
});
|
||||
} else {
|
||||
count = await this.requestManagementDbService.countByFilter({
|
||||
blameStatus: status,
|
||||
$or: [
|
||||
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
|
||||
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
|
||||
],
|
||||
});
|
||||
}
|
||||
data[status] = count;
|
||||
data.all += count;
|
||||
if (fromDate && Number.isNaN(fromDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'from' date");
|
||||
}
|
||||
return data;
|
||||
if (toDate && Number.isNaN(toDate.getTime())) {
|
||||
throw new BadRequestException("Invalid 'to' date");
|
||||
}
|
||||
if (fromDate && toDate && fromDate > toDate) {
|
||||
throw new BadRequestException("'from' must be before or equal to 'to'");
|
||||
}
|
||||
return { fromDate, toDate };
|
||||
}
|
||||
|
||||
async getDateFilteredRequestByStatus(
|
||||
role: string,
|
||||
client: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) {
|
||||
const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"];
|
||||
const data: Record<string, number> = {};
|
||||
const clientId = new Types.ObjectId(client);
|
||||
|
||||
for (const status of statuses) {
|
||||
let blameCount = 0;
|
||||
let claimCount = 0;
|
||||
|
||||
if (role === "expert" || role === "company") {
|
||||
blameCount = await this.requestManagementDbService.countByFilter({
|
||||
blameStatus: status,
|
||||
createdAt: { $gte: start, $lte: end },
|
||||
$or: [
|
||||
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
|
||||
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (role === "damage_expert" || role === "company") {
|
||||
claimCount = await this.claimRequestManagementDbService.countByFilter({
|
||||
claimStatus: status,
|
||||
userClientKey: clientId,
|
||||
createdAt: { $gte: start, $lte: end },
|
||||
});
|
||||
}
|
||||
data[status] = blameCount + claimCount;
|
||||
}
|
||||
return data;
|
||||
private isInDateRange(
|
||||
value: unknown,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): boolean {
|
||||
if (!fromDate && !toDate) return true;
|
||||
if (!value) return false;
|
||||
const d = new Date(value as string | Date);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
if (fromDate && d < fromDate) return false;
|
||||
if (toDate && d > toDate) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isVisibleToClientType(client: any, actor: any): boolean {
|
||||
if (actor.userType === UserType.GENUINE) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
actor.userType === UserType.LEGAL &&
|
||||
String(client._id) === actor.clientKey
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
private publicIdKey(doc: Record<string, unknown>): string {
|
||||
return String(doc.publicId ?? doc._id ?? "");
|
||||
}
|
||||
|
||||
private wasHandledByActor(request: any, actorSub: string): boolean {
|
||||
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
|
||||
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
|
||||
|
||||
if (!Array.isArray(actorChecker)) {
|
||||
return false;
|
||||
private buildClaimBucketsForInsurer(
|
||||
rows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): Record<string, number> {
|
||||
const buckets = initialClaimExpertReportBuckets();
|
||||
for (const doc of rows) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = claimCaseStatusToReportBucket(st);
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
const matchingEntry = actorChecker.find(
|
||||
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
|
||||
);
|
||||
|
||||
return !!matchingEntry;
|
||||
private buildBlameBucketsForInsurer(
|
||||
rows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): Record<string, number> {
|
||||
const buckets = initialBlameExpertReportBuckets();
|
||||
for (const doc of rows) {
|
||||
if (!blameCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const st = String(doc.status ?? "");
|
||||
const key = blameCaseStatusToReportBucket(st);
|
||||
buckets.all++;
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters claim requests using the same logic as expert-claim service
|
||||
* to ensure consistency between endpoints
|
||||
* One insurer “file” per `publicId`, aligned with `ExpertInsurerService.retrieveAllFilesOfClient`.
|
||||
*/
|
||||
private async filterClaimRequestsForExpert(
|
||||
requests: any[],
|
||||
actor: any,
|
||||
): Promise<any[]> {
|
||||
const filteredRequests = [];
|
||||
|
||||
for (const r of requests) {
|
||||
// For expert-initiated blame files, only show to the initiating expert
|
||||
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
|
||||
if (String(r.blameFile.initiatedBy) !== actor.sub) {
|
||||
continue; // Skip if not the initiating expert
|
||||
}
|
||||
// Expert-initiated claim files are always visible to the initiating expert
|
||||
filteredRequests.push(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = await this.clientDbService.findOne({
|
||||
_id: r.userClientKey,
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
this.logger.warn(
|
||||
`Client not found for claim request with ID: ${r._id}. Skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const specialHandlingStatuses = [
|
||||
ReqClaimStatus.CheckAgain,
|
||||
ReqClaimStatus.ReviewRequest,
|
||||
ReqClaimStatus.PendingFactorValidation,
|
||||
];
|
||||
|
||||
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
|
||||
r.claimStatus,
|
||||
);
|
||||
|
||||
if (requiresSpecificActorCheck) {
|
||||
if (this.wasHandledByActor(r, actor.sub)) {
|
||||
filteredRequests.push(r);
|
||||
}
|
||||
} else {
|
||||
if (this.isVisibleToClientType(client, actor)) {
|
||||
filteredRequests.push(r);
|
||||
}
|
||||
}
|
||||
private countUnifiedByPublicId(
|
||||
blameRows: Record<string, unknown>[],
|
||||
claimRows: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): number {
|
||||
const keys = new Set<string>();
|
||||
for (const doc of blameRows) {
|
||||
if (!blameCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const k = this.publicIdKey(doc);
|
||||
if (k) keys.add(k);
|
||||
}
|
||||
|
||||
return filteredRequests;
|
||||
for (const doc of claimRows) {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) continue;
|
||||
if (!this.isInDateRange(doc.createdAt, dateFrom, dateTo)) continue;
|
||||
const k = this.publicIdKey(doc);
|
||||
if (k) keys.add(k);
|
||||
}
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
async getAllRequestsCountByRole(role: string, client: string, actor?: any) {
|
||||
if (role === "expert") {
|
||||
const statuses = Object.values(ReqBlameStatus);
|
||||
const data: Record<string, number> = { all: 0 };
|
||||
|
||||
for (const status of statuses) {
|
||||
const filter = {
|
||||
blameStatus: status,
|
||||
$or: [
|
||||
{
|
||||
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
|
||||
client,
|
||||
),
|
||||
},
|
||||
// {
|
||||
// "secondPartyDetails.secondPartyClient.clientId":
|
||||
// new Types.ObjectId(client),
|
||||
// },
|
||||
],
|
||||
};
|
||||
|
||||
const count =
|
||||
await this.requestManagementDbService.countByFilter(filter);
|
||||
data[status] = count;
|
||||
data.all += count;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
if (role === "damage_expert") {
|
||||
const statuses = Object.values(ReqClaimStatus);
|
||||
const data: Record<string, number> = { all: 0 };
|
||||
|
||||
// For damage_expert, we need to apply the same filtering as expert-claim service
|
||||
if (actor) {
|
||||
// Fetch all requests with the statuses that expert-claim service shows
|
||||
// This matches the statuses in getClaimRequestsListForExpert
|
||||
const relevantStatuses = [
|
||||
ReqClaimStatus.UnChecked,
|
||||
ReqClaimStatus.ReviewRequest,
|
||||
ReqClaimStatus.CheckAgain,
|
||||
ReqClaimStatus.CloseRequest,
|
||||
ReqClaimStatus.InPersonVisit,
|
||||
ReqClaimStatus.CheckedRequest,
|
||||
ReqClaimStatus.PendingFactorValidation,
|
||||
];
|
||||
|
||||
// Fetch all requests with relevant statuses (matching expert-claim query)
|
||||
const allRequests =
|
||||
await this.claimRequestManagementDbService.findAllByStatus({
|
||||
claimStatus: { $in: relevantStatuses },
|
||||
});
|
||||
|
||||
// Filter requests using the same logic as expert-claim service
|
||||
const filteredRequests =
|
||||
await this.filterClaimRequestsForExpert(allRequests, actor);
|
||||
|
||||
// Count by status from filtered results
|
||||
for (const status of statuses) {
|
||||
const count = filteredRequests.filter(
|
||||
(r) => r.claimStatus === status,
|
||||
).length;
|
||||
data[status] = count;
|
||||
data.all += count;
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
|
||||
for (const status of statuses) {
|
||||
const filter = {
|
||||
claimStatus: status,
|
||||
userClientKey: new Types.ObjectId(client),
|
||||
};
|
||||
|
||||
const count =
|
||||
await this.claimRequestManagementDbService.countByFilter(filter);
|
||||
data[status] = count;
|
||||
data.all += count;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ✅ Company logic
|
||||
if (role === "company") {
|
||||
const blameStatuses = Object.values(ReqBlameStatus);
|
||||
const claimStatuses = Object.values(ReqClaimStatus);
|
||||
|
||||
const blameData: Record<string, number> = { all: 0 };
|
||||
const claimData: Record<string, number> = { all: 0 };
|
||||
|
||||
// Only count files where this company is FIRST party in Blame
|
||||
for (const status of blameStatuses) {
|
||||
const filter = {
|
||||
blameStatus: status,
|
||||
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
|
||||
client,
|
||||
),
|
||||
};
|
||||
|
||||
const count =
|
||||
await this.requestManagementDbService.countByFilter(filter);
|
||||
blameData[status] = count;
|
||||
blameData.all += count;
|
||||
}
|
||||
|
||||
// Claims always use `userClientKey`
|
||||
for (const status of claimStatuses) {
|
||||
const filter = {
|
||||
claimStatus: status,
|
||||
userClientKey: new Types.ObjectId(client),
|
||||
};
|
||||
|
||||
const count =
|
||||
await this.claimRequestManagementDbService.countByFilter(filter);
|
||||
claimData[status] = count;
|
||||
claimData.all += count;
|
||||
}
|
||||
|
||||
return { blameData, claimData };
|
||||
}
|
||||
|
||||
return null;
|
||||
private buildInsurerSummary(
|
||||
blames: Record<string, unknown>[],
|
||||
claims: Record<string, unknown>[],
|
||||
clientKey: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date,
|
||||
): InsurerRequestsSummaryDtoRs {
|
||||
const claim = this.buildClaimBucketsForInsurer(
|
||||
claims,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
const blame = this.buildBlameBucketsForInsurer(
|
||||
blames,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
const unifiedTotal = this.countUnifiedByPublicId(
|
||||
blames,
|
||||
claims,
|
||||
clientKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
);
|
||||
return new InsurerRequestsSummaryDtoRs(claim, blame, unifiedTotal);
|
||||
}
|
||||
|
||||
async getAllRequestsReportCount(actor, client) {
|
||||
if (actor.role === "damage_expert") {
|
||||
// Pass actor to apply filtering logic for damage_expert
|
||||
const data = await this.getAllRequestsCountByRole(
|
||||
actor.role,
|
||||
client,
|
||||
actor,
|
||||
);
|
||||
return new DamageExpertAllRequestsCountReportDtoRs(data);
|
||||
} else if (actor.role === "expert") {
|
||||
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
||||
return new ExpertAllRequestsCountReportDtoRs(data);
|
||||
} else {
|
||||
// company
|
||||
const damageExpertData = await this.getAllRequestsCountByRole(
|
||||
"damage_expert",
|
||||
client,
|
||||
);
|
||||
const expertData = await this.getAllRequestsCountByRole("expert", client);
|
||||
return new CompanyAllRequestsCountReportDtoRs(
|
||||
expertData,
|
||||
damageExpertData,
|
||||
);
|
||||
}
|
||||
async getInsurerRequestsSummary(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerRequestsSummaryDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
return this.buildInsurerSummary(
|
||||
blames as Record<string, unknown>[],
|
||||
claims as Record<string, unknown>[],
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
async getAllRequestsReportPerMonth(actor, client) {
|
||||
const result = [];
|
||||
async getInsurerRequestsSummaryPerMonth(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerPerMonthReportRowDtoRs[]> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
const blameRows = blames as Record<string, unknown>[];
|
||||
const claimRows = claims as Record<string, unknown>[];
|
||||
|
||||
const result: InsurerPerMonthReportRowDtoRs[] = [];
|
||||
const now = new Date();
|
||||
|
||||
if (actor.role === "company") {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - i + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
);
|
||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - i + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
);
|
||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const blameData = await this.getDateFilteredRequestByStatus(
|
||||
"expert",
|
||||
client,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
const claimData = await this.getDateFilteredRequestByStatus(
|
||||
"damage_expert",
|
||||
client,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
const data = this.buildInsurerSummary(
|
||||
blameRows,
|
||||
claimRows,
|
||||
clientKey,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
result.unshift({
|
||||
stDate: monthStart,
|
||||
enDate: monthEnd,
|
||||
faLabel: label,
|
||||
data: {
|
||||
blame: blameData,
|
||||
claim: claimData,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - i + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
);
|
||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const data = await this.getDateFilteredRequestByStatus(
|
||||
actor.role,
|
||||
client,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
result.unshift({
|
||||
stDate: monthStart,
|
||||
enDate: monthEnd,
|
||||
faLabel: label,
|
||||
data,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
result.unshift(
|
||||
new InsurerPerMonthReportRowDtoRs(monthStart, monthEnd, label, data),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAllCheckedRequestsCount(actor, client) {
|
||||
if (actor.role === "damage_expert" || actor.role === "expert") {
|
||||
return this.getAllCheckedRequestsCountFn(actor.role, client);
|
||||
} else {
|
||||
const blame = await this.getAllCheckedRequestsCountFn("expert", client);
|
||||
const claim = await this.getAllCheckedRequestsCountFn(
|
||||
"damage_expert",
|
||||
client,
|
||||
);
|
||||
return { blame, claim };
|
||||
async getInsurerRequestsSummaryByDateRange(
|
||||
actor: { clientKey?: string },
|
||||
from?: string,
|
||||
to?: string,
|
||||
): Promise<InsurerRequestsSummaryDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { fromDate, toDate } = this.parseDateRange(from, to);
|
||||
|
||||
const [blames, claims] = await Promise.all([
|
||||
this.blameRequestDbService.find({}, { lean: true }),
|
||||
this.claimCaseDbService.find({}, { lean: true }),
|
||||
]);
|
||||
return this.buildInsurerSummary(
|
||||
blames as Record<string, unknown>[],
|
||||
claims as Record<string, unknown>[],
|
||||
clientKey,
|
||||
fromDate,
|
||||
toDate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
|
||||
private replayExpertFileStates(
|
||||
events: NormalizedActivity[],
|
||||
cutoff: Date,
|
||||
): Map<string, { checked: boolean; handled: boolean }> {
|
||||
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
const filtered = events.filter((e) => e.occurredAt <= cutoff);
|
||||
filtered.sort((a, b) => {
|
||||
const t = a.occurredAt.getTime() - b.occurredAt.getTime();
|
||||
if (t !== 0) return t;
|
||||
return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`);
|
||||
});
|
||||
for (const e of filtered) {
|
||||
const key = `${e.expertId}:${e.fileId}`;
|
||||
const prev = stateByKey.get(key) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByKey.set(key, prev);
|
||||
}
|
||||
return stateByKey;
|
||||
}
|
||||
|
||||
private snapshotCountsForExpert(
|
||||
stateByKey: Map<string, { checked: boolean; handled: boolean }>,
|
||||
expertId: string,
|
||||
): { totalHandled: number; currentlyChecking: number } {
|
||||
let totalHandled = 0;
|
||||
let currentlyChecking = 0;
|
||||
const prefix = `${expertId}:`;
|
||||
for (const [key, st] of stateByKey.entries()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (st.handled) totalHandled++;
|
||||
else if (st.checked) currentlyChecking++;
|
||||
}
|
||||
return { totalHandled, currentlyChecking };
|
||||
}
|
||||
|
||||
private distinctFilesWithCheckInWindow(
|
||||
events: NormalizedActivity[],
|
||||
expertId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): number {
|
||||
const files = new Set<string>();
|
||||
for (const e of events) {
|
||||
if (e.expertId !== expertId) continue;
|
||||
if (e.eventType !== ExpertFileActivityType.CHECKED) continue;
|
||||
if (e.occurredAt < from || e.occurredAt > to) continue;
|
||||
files.add(e.fileId);
|
||||
}
|
||||
return files.size;
|
||||
}
|
||||
|
||||
private async loadTenantExpertsAndActivities(clientKey: string): Promise<{
|
||||
events: NormalizedActivity[];
|
||||
expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}>;
|
||||
}> {
|
||||
const tenantOid = new Types.ObjectId(clientKey);
|
||||
const tenantStr = String(tenantOid);
|
||||
|
||||
/**
|
||||
* `clientKey` is sometimes stored as ObjectId, sometimes as string — match both.
|
||||
* Activity is loaded for the whole tenant so claim (V2) damage experts still appear
|
||||
* even if a roster row was missed by shape mismatches; we then merge `_id` lookups.
|
||||
*/
|
||||
const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([
|
||||
this.expertDbService.findAll({
|
||||
$or: [{ clientKey: tenantStr }, { clientKey: tenantOid }],
|
||||
} as never),
|
||||
this.damageExpertDbService.findAll({
|
||||
$or: [{ clientKey: tenantOid }, { clientKey: tenantStr }],
|
||||
} as never),
|
||||
this.expertFileActivityDbService.findByTenant(tenantOid),
|
||||
]);
|
||||
|
||||
const expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}> = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => {
|
||||
if (seenIds.has(id)) return;
|
||||
seenIds.add(id);
|
||||
expertRows.push({ id, fullName, kind });
|
||||
};
|
||||
|
||||
for (const e of blamePanelExperts) {
|
||||
const fullName =
|
||||
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
|
||||
(e as any).email ||
|
||||
String((e as any)._id);
|
||||
pushRow(String((e as any)._id), fullName, "expert");
|
||||
}
|
||||
for (const e of damageExperts) {
|
||||
const fullName =
|
||||
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
|
||||
(e as any).email ||
|
||||
String((e as any)._id);
|
||||
pushRow(String((e as any)._id), fullName, "damage_expert");
|
||||
}
|
||||
|
||||
const activityExpertIds = [
|
||||
...new Set(
|
||||
rawEvents
|
||||
.map((row) => String(row.expertId))
|
||||
.filter((id) => Types.ObjectId.isValid(id)),
|
||||
),
|
||||
].filter((id) => !seenIds.has(id));
|
||||
|
||||
if (activityExpertIds.length > 0) {
|
||||
const oids = activityExpertIds.map((id) => new Types.ObjectId(id));
|
||||
const [extraDamages, extraExperts] = await Promise.all([
|
||||
this.damageExpertDbService.findAll({ _id: { $in: oids } } as never),
|
||||
this.expertDbService.findAll({ _id: { $in: oids } } as never),
|
||||
]);
|
||||
for (const d of extraDamages) {
|
||||
const id = String((d as any)._id);
|
||||
if (seenIds.has(id)) continue;
|
||||
const fullName =
|
||||
`${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() ||
|
||||
(d as any).email ||
|
||||
id;
|
||||
pushRow(id, fullName, "damage_expert");
|
||||
}
|
||||
for (const x of extraExperts) {
|
||||
const id = String((x as any)._id);
|
||||
if (seenIds.has(id)) continue;
|
||||
const fullName =
|
||||
`${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() ||
|
||||
(x as any).email ||
|
||||
id;
|
||||
pushRow(id, fullName, "expert");
|
||||
}
|
||||
}
|
||||
|
||||
const events: NormalizedActivity[] = rawEvents.map((row) => ({
|
||||
expertId: String(row.expertId),
|
||||
fileId: String(row.fileId),
|
||||
eventType: row.eventType,
|
||||
occurredAt:
|
||||
row.occurredAt instanceof Date
|
||||
? row.occurredAt
|
||||
: new Date(row.occurredAt as string | Date),
|
||||
}));
|
||||
|
||||
return { events, expertRows };
|
||||
}
|
||||
|
||||
private buildWorkLogEntries(
|
||||
expertRows: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: "expert" | "damage_expert";
|
||||
}>,
|
||||
events: NormalizedActivity[],
|
||||
cutoff: Date,
|
||||
checkedWindow: { from: Date; to: Date },
|
||||
): InsurerExpertWorkLogEntryDtoRs[] {
|
||||
const state = this.replayExpertFileStates(events, cutoff);
|
||||
return expertRows.map((row) => {
|
||||
const snap = this.snapshotCountsForExpert(state, row.id);
|
||||
return new InsurerExpertWorkLogEntryDtoRs(
|
||||
row.id,
|
||||
row.fullName,
|
||||
row.kind,
|
||||
snap.totalHandled,
|
||||
snap.currentlyChecking,
|
||||
this.distinctFilesWithCheckInWindow(
|
||||
events,
|
||||
row.id,
|
||||
checkedWindow.from,
|
||||
checkedWindow.to,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getInsurerExpertWorkLog(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { events, expertRows } =
|
||||
await this.loadTenantExpertsAndActivities(clientKey);
|
||||
const now = new Date();
|
||||
const epoch = new Date(0);
|
||||
const entries = this.buildWorkLogEntries(expertRows, events, now, {
|
||||
from: epoch,
|
||||
to: now,
|
||||
});
|
||||
return new InsurerExpertWorkLogResponseDtoRs(entries);
|
||||
}
|
||||
|
||||
async getInsurerExpertWorkLogPerMonth(actor: {
|
||||
clientKey?: string;
|
||||
}): Promise<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const { events, expertRows } =
|
||||
await this.loadTenantExpertsAndActivities(clientKey);
|
||||
const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const monthEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - i + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
);
|
||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const experts = this.buildWorkLogEntries(
|
||||
expertRows,
|
||||
events,
|
||||
monthEnd,
|
||||
{ from: monthStart, to: monthEnd },
|
||||
);
|
||||
|
||||
result.unshift(
|
||||
new InsurerExpertWorkLogPerMonthRowDtoRs(
|
||||
monthStart,
|
||||
monthEnd,
|
||||
label,
|
||||
experts,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
19
src/request-management/dto/claim-creation-hint.dto.ts
Normal file
19
src/request-management/dto/claim-creation-hint.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Computed on GET blame v2 for the current viewer — no DB field on BlameRequest.
|
||||
*/
|
||||
export class ClaimCreationHintDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True if a v2 claim case already exists for this blame (by blameRequestId).",
|
||||
})
|
||||
hasClaim: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when this viewer is the damaged party, blame is COMPLETED, no claim exists yet, " +
|
||||
"and for THIRD_PARTY all parties have signed and accepted the expert decision.",
|
||||
})
|
||||
shouldGuideToCreateClaim: boolean;
|
||||
}
|
||||
@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
|
||||
}
|
||||
|
||||
export class DescriptionDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: "Accident description (required for all types)" })
|
||||
desc: string;
|
||||
|
||||
// CAR_BODY specific fields
|
||||
@ApiPropertyOptional({
|
||||
description: "Date of accident (for CAR_BODY type only)",
|
||||
example: "2025-12-08"
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: "2025-12-08",
|
||||
})
|
||||
accidentDate?: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Time of accident (for CAR_BODY type only)",
|
||||
example: "14:30"
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: "14:30",
|
||||
})
|
||||
accidentTime?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WeatherCondition,
|
||||
description: "Weather condition at time of accident (for CAR_BODY type only)",
|
||||
example: WeatherCondition.CLEAR
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: WeatherCondition.CLEAR,
|
||||
})
|
||||
weatherCondition?: WeatherCondition;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: RoadCondition,
|
||||
description: "Road condition at time of accident (for CAR_BODY type only)",
|
||||
example: RoadCondition.DRY
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: RoadCondition.DRY,
|
||||
})
|
||||
roadCondition?: RoadCondition;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: LightCondition,
|
||||
description: "Light condition at time of accident (for CAR_BODY type only)",
|
||||
example: LightCondition.DAYLIGHT
|
||||
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||
example: LightCondition.DAYLIGHT,
|
||||
})
|
||||
lightCondition?: LightCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIRD_PARTY description step: only desc is accepted.
|
||||
*/
|
||||
export class ThirdPartyDescriptionDto {
|
||||
@ApiProperty({ description: "Accident description" })
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CAR_BODY description step: desc + accident date/time and conditions required.
|
||||
*/
|
||||
export class CarBodyDescriptionDto {
|
||||
@ApiProperty({ description: "Accident description" })
|
||||
desc: string;
|
||||
|
||||
@ApiProperty({ description: "Date of accident", example: "2025-12-08" })
|
||||
accidentDate: Date;
|
||||
|
||||
@ApiProperty({ description: "Time of accident", example: "14:30" })
|
||||
accidentTime: string;
|
||||
|
||||
@ApiProperty({ enum: WeatherCondition, example: WeatherCondition.CLEAR })
|
||||
weatherCondition: WeatherCondition;
|
||||
|
||||
@ApiProperty({ enum: RoadCondition, example: RoadCondition.DRY })
|
||||
roadCondition: RoadCondition;
|
||||
|
||||
@ApiProperty({ enum: LightCondition, example: LightCondition.DAYLIGHT })
|
||||
lightCondition: LightCondition;
|
||||
}
|
||||
|
||||
export class FirstPartyDetail {
|
||||
// @ApiProperty({ type: String })
|
||||
firstPartyId?: Types.ObjectId;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { LocationDto } from "./create-request-management.dto";
|
||||
import { DescriptionDto } from "./create-request-management.dto";
|
||||
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
|
||||
|
||||
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
firstPartyPlate: AddPlateDto;
|
||||
|
||||
@ApiProperty({ type: LocationDto })
|
||||
firstPartyLocation: LocationDto;
|
||||
|
||||
@ApiProperty({ type: DescriptionDto })
|
||||
firstPartyDescription: DescriptionDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import {
|
||||
CarBodyFormDto,
|
||||
DescriptionDto,
|
||||
InitialFormDto,
|
||||
} from "./create-request-management.dto";
|
||||
|
||||
/**
|
||||
* V2 (IN_PERSON): single expert description/location for the scene.
|
||||
*/
|
||||
export class ExpertCompleteCarBodyFormV2Dto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiProperty({ type: InitialFormDto })
|
||||
firstPartyInitialForm: InitialFormDto;
|
||||
|
||||
@ApiProperty({ type: CarBodyFormDto })
|
||||
carBodyForm: CarBodyFormDto;
|
||||
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
firstPartyPlate: AddPlateDto;
|
||||
|
||||
@ApiProperty({ type: DescriptionDto })
|
||||
expertDescription: DescriptionDto;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { LocationDto } from "./create-request-management.dto";
|
||||
|
||||
/**
|
||||
* V2 (IN_PERSON): one scene location provided by expert.
|
||||
*/
|
||||
export class ExpertCompleteLocationV2Dto {
|
||||
@ApiProperty({ type: LocationDto })
|
||||
location: LocationDto;
|
||||
}
|
||||
11
src/request-management/dto/expert-complete-locations.dto.ts
Normal file
11
src/request-management/dto/expert-complete-locations.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { LocationDto } from "./create-request-management.dto";
|
||||
|
||||
export class ExpertCompleteLocationsDto {
|
||||
@ApiProperty({ type: LocationDto })
|
||||
firstPartyLocation: LocationDto;
|
||||
|
||||
@ApiPropertyOptional({ type: LocationDto })
|
||||
secondPartyLocation?: LocationDto;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { LocationDto } from "./create-request-management.dto";
|
||||
import { DescriptionDto } from "./create-request-management.dto";
|
||||
import { InitialFormDto } from "./create-request-management.dto";
|
||||
|
||||
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
plate: AddPlateDto;
|
||||
|
||||
@ApiProperty({ type: LocationDto })
|
||||
location: LocationDto;
|
||||
|
||||
@ApiProperty({ type: DescriptionDto })
|
||||
description: DescriptionDto;
|
||||
}
|
||||
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
firstPartyPlate: AddPlateDto;
|
||||
|
||||
@ApiProperty({ type: LocationDto })
|
||||
firstPartyLocation: LocationDto;
|
||||
|
||||
@ApiProperty({ type: DescriptionDto })
|
||||
firstPartyDescription: DescriptionDto;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { DescriptionDto, InitialFormDto } from "./create-request-management.dto";
|
||||
|
||||
/**
|
||||
* V2 (IN_PERSON): second party inputs without separate description/location.
|
||||
*/
|
||||
export class ExpertSecondPartyInfoV2Dto {
|
||||
@ApiProperty({
|
||||
description: "Second party phone number",
|
||||
example: "09123456789",
|
||||
})
|
||||
phoneNumber: string;
|
||||
|
||||
@ApiProperty({ type: InitialFormDto })
|
||||
initialForm: InitialFormDto;
|
||||
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
plate: AddPlateDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 (IN_PERSON): single expert description shared for the whole scene.
|
||||
*/
|
||||
export class ExpertCompleteThirdPartyFormV2Dto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiProperty({ type: InitialFormDto })
|
||||
firstPartyInitialForm: InitialFormDto;
|
||||
|
||||
@ApiProperty({ type: AddPlateDto })
|
||||
firstPartyPlate: AddPlateDto;
|
||||
|
||||
@ApiProperty({ type: ExpertSecondPartyInfoV2Dto })
|
||||
secondParty: ExpertSecondPartyInfoV2Dto;
|
||||
|
||||
@ApiProperty({ type: DescriptionDto })
|
||||
expertDescription: DescriptionDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Phone number of the guilty party. Must match either first or second party phone number.",
|
||||
example: "09123456789",
|
||||
})
|
||||
guiltyPartyPhoneNumber: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert uploading a party's signature for expert-initiated IN_PERSON blame.
|
||||
*/
|
||||
export class ExpertUploadPartySignatureDto {
|
||||
@ApiProperty({
|
||||
enum: ["FIRST", "SECOND"],
|
||||
description: "Which party's signature is being uploaded. CAR_BODY: use FIRST only. THIRD_PARTY: FIRST and SECOND.",
|
||||
})
|
||||
partyRole: "FIRST" | "SECOND";
|
||||
|
||||
@ApiProperty({
|
||||
default: true,
|
||||
description: "Party accepts the blame agreement (true). Set false if party rejects.",
|
||||
})
|
||||
isAccept: boolean;
|
||||
}
|
||||
10
src/request-management/dto/registrar-initiated.dto.ts
Normal file
10
src/request-management/dto/registrar-initiated.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsString } from "class-validator";
|
||||
|
||||
export class CreateRegistrarInitiatedFileDto {
|
||||
@ApiProperty({ enum: ["THIRD_PARTY", "CAR_BODY"], example: "THIRD_PARTY" })
|
||||
@IsString()
|
||||
@IsIn(["THIRD_PARTY", "CAR_BODY"])
|
||||
type: "THIRD_PARTY" | "CAR_BODY";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class SendExpertInitiatedLinkV2Dto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Frontend route path (same as add-second-party flow), e.g. requestManagement/firstParty",
|
||||
example: "requestManagement/firstParty",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
frontendRoute: string;
|
||||
}
|
||||
19
src/request-management/dto/send-party-otps.dto.ts
Normal file
19
src/request-management/dto/send-party-otps.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert to request OTP send to one or two parties for expert-initiated IN_PERSON blame.
|
||||
* Uses the same SMS flow as /user/send-otp. After this, parties receive SMS with OTP; expert collects and calls verify-party-otps.
|
||||
*/
|
||||
export class SendPartyOtpsDto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number (e.g. 09123456789). SMS with OTP will be sent to this number.",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Second party phone number. Required when blame type is THIRD_PARTY. SMS with OTP will be sent to this number.",
|
||||
example: "09187654321",
|
||||
})
|
||||
secondPartyPhoneNumber?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
export class UserResendResponseDto {
|
||||
@ApiProperty({
|
||||
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
|
||||
description: "Whether the user has completed uploading all requested items",
|
||||
})
|
||||
completed: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Per-item UI hint: document_camera (open camera), voice, video, or text (multiline field).",
|
||||
example: [
|
||||
{ item: "drivingLicense", inputKind: "document_camera" },
|
||||
{ item: "description", inputKind: "text" },
|
||||
],
|
||||
})
|
||||
requestedItemsUi?: { item: string; inputKind: string }[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Requested items this party has not yet satisfied (partial resend).",
|
||||
example: ["carCertificate"],
|
||||
})
|
||||
remainingItems?: string[];
|
||||
}
|
||||
|
||||
export class UserResendUploadResponseDto {
|
||||
|
||||
31
src/request-management/dto/verify-party-otps.dto.ts
Normal file
31
src/request-management/dto/verify-party-otps.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
/**
|
||||
* Body for field expert to verify one or two party OTPs for expert-initiated IN_PERSON blame.
|
||||
* First party must verify; second party required only when type is THIRD_PARTY.
|
||||
*/
|
||||
export class VerifyPartyOtpsDto {
|
||||
@ApiProperty({
|
||||
description: "First party phone number (must have requested OTP via /user/send-otp)",
|
||||
example: "09123456789",
|
||||
})
|
||||
firstPartyPhoneNumber: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "OTP received by first party",
|
||||
example: "258567",
|
||||
})
|
||||
firstPartyOtp: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Second party phone number. Required when blame type is THIRD_PARTY.",
|
||||
example: "09187654321",
|
||||
})
|
||||
secondPartyPhoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "OTP received by second party. Required when secondPartyPhoneNumber is provided.",
|
||||
example: "123456",
|
||||
})
|
||||
secondPartyOtp?: string;
|
||||
}
|
||||
@@ -50,5 +50,10 @@ export class AccidentInfo {
|
||||
|
||||
@Prop({ type: AccidentClassificationSchema })
|
||||
classification?: AccidentClassification;
|
||||
|
||||
/** CAR_BODY: weather / road / light at time of accident */
|
||||
@Prop() weatherCondition?: string;
|
||||
@Prop() roadCondition?: string;
|
||||
@Prop() lightCondition?: string;
|
||||
}
|
||||
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);
|
||||
@@ -1,19 +1,27 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument } from "mongoose";
|
||||
import { HydratedDocument, Types } from "mongoose";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { Party, PartySchema } from "./partyRole.enum";
|
||||
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
|
||||
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
||||
import { Workflow, WorkflowSchema } from "./workflow.type";
|
||||
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
|
||||
import { CreationMethod, FilledBy } from "./request-management.schema";
|
||||
|
||||
/** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */
|
||||
@Schema({ _id: false })
|
||||
export class CarBodyInsuranceDetail {
|
||||
@Prop() policyNumber?: string;
|
||||
@Prop() startDate?: string;
|
||||
@Prop() endDate?: string;
|
||||
@Prop() insurerCompany?: string;
|
||||
@Prop({ type: [String] }) coverages?: string[];
|
||||
}
|
||||
export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class BlameRequestSnapshot {
|
||||
@Prop({ type: AccidentInfoSchema })
|
||||
accident?: AccidentInfo;
|
||||
|
||||
@Prop({ type: [PartySchema], default: [] })
|
||||
parties?: Party[];
|
||||
}
|
||||
@@ -45,9 +53,6 @@ export class BlameRequest {
|
||||
@Prop({ type: WorkflowSchema })
|
||||
workflow?: Workflow;
|
||||
|
||||
@Prop({ type: AccidentInfoSchema })
|
||||
accident?: AccidentInfo;
|
||||
|
||||
@Prop({ type: [PartySchema], default: [] })
|
||||
parties: Party[];
|
||||
|
||||
@@ -57,12 +62,40 @@ export class BlameRequest {
|
||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||
history: HistoryEvent[];
|
||||
|
||||
/** CAR_BODY only: first party car-body insurance (mocked inquiry) – legacy; prefer parties[0].insurance.carBodyInsurance */
|
||||
@Prop({ type: CarBodyInsuranceDetailSchema })
|
||||
carBodyInsuranceDetail?: CarBodyInsuranceDetail;
|
||||
|
||||
/**
|
||||
* Optional snapshot structure (kept for future use / read-optimized copies).
|
||||
* Source of truth remains the top-level fields of this document.
|
||||
*/
|
||||
@Prop({ type: BlameRequestSnapshotSchema, required: false })
|
||||
snapshot?: BlameRequestSnapshot;
|
||||
|
||||
/** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */
|
||||
@Prop({ default: false })
|
||||
expertInitiated?: boolean;
|
||||
|
||||
/** Field expert who created this file (for expert-initiated flows). */
|
||||
@Prop({ type: Types.ObjectId })
|
||||
initiatedByFieldExpertId?: Types.ObjectId;
|
||||
|
||||
/** True when this blame was created by a registrar. */
|
||||
@Prop({ default: false })
|
||||
registrarInitiated?: boolean;
|
||||
|
||||
/** Registrar who created this file (for registrar-initiated flows). */
|
||||
@Prop({ type: Types.ObjectId })
|
||||
initiatedByRegistrarId?: Types.ObjectId;
|
||||
|
||||
/** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */
|
||||
@Prop({ type: String, enum: CreationMethod })
|
||||
creationMethod?: CreationMethod;
|
||||
|
||||
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
|
||||
@Prop({ type: String, enum: FilledBy })
|
||||
filledBy?: FilledBy;
|
||||
}
|
||||
|
||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
|
||||
/**
|
||||
* Immutable snapshot of the expert at reply time (field expert or damage expert).
|
||||
* Stored on blame `expert.decision` and claim `evaluation.damageExpertReply*`.
|
||||
*/
|
||||
@Schema({ _id: false })
|
||||
export class ExpertProfileSnapshot {
|
||||
@Prop()
|
||||
firstName?: string;
|
||||
|
||||
@Prop()
|
||||
lastName?: string;
|
||||
|
||||
@Prop()
|
||||
email?: string;
|
||||
|
||||
@Prop()
|
||||
insuActivityCo?: string;
|
||||
|
||||
@Prop()
|
||||
mobile?: string;
|
||||
}
|
||||
|
||||
export const ExpertProfileSnapshotSchema =
|
||||
SchemaFactory.createForClass(ExpertProfileSnapshot);
|
||||
@@ -1,6 +1,10 @@
|
||||
//! NEW
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "./expert-profile-snapshot.schema";
|
||||
|
||||
|
||||
@Schema({ _id: false })
|
||||
@@ -52,6 +56,10 @@ export class ExpertDecision {
|
||||
accidentReason: { id: string; label: string; fanavaran: number };
|
||||
accidentType: { id: string; label: string };
|
||||
};
|
||||
|
||||
/** Field expert profile at decision time (from `expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ExpertDecisionSchema = SchemaFactory.createForClass(ExpertDecision);
|
||||
|
||||
@@ -64,6 +72,10 @@ export class PartyResendRequest {
|
||||
@Prop({ type: [String], default: [] })
|
||||
requestedItems: string[]; // Array of ResendItemType values
|
||||
|
||||
/** @deprecated Resend UI uses requestedItems + inputKind from API; do not rely on workflow steps. */
|
||||
@Prop({ type: [String], default: [] })
|
||||
workflowStepKeys?: string[];
|
||||
|
||||
@Prop()
|
||||
description?: string;
|
||||
|
||||
@@ -75,6 +87,20 @@ export class PartyResendRequest {
|
||||
|
||||
@Prop({ type: Date })
|
||||
completedAt?: Date;
|
||||
|
||||
/** Document resend uploads keyed by ResendItemType (e.g. drivingLicense). */
|
||||
@Prop({ type: Object, default: {} })
|
||||
uploadedDocuments?: Record<string, Types.ObjectId>;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
resendVoiceId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
resendVideoId?: Types.ObjectId;
|
||||
|
||||
/** Fulfills requestedItems `description` (text). */
|
||||
@Prop({ type: String })
|
||||
userTextDescription?: string;
|
||||
}
|
||||
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
|
||||
|
||||
@@ -89,6 +115,16 @@ export class ExpertResend {
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
requestedByExpertId?: Types.ObjectId;
|
||||
|
||||
/** Field expert profile when resend was requested (`expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
|
||||
/**
|
||||
* @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind.
|
||||
*/
|
||||
@Prop({ type: [String], default: [] })
|
||||
combinedResubmitStepKeys?: string[];
|
||||
}
|
||||
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);
|
||||
|
||||
|
||||
@@ -70,6 +70,16 @@ export class Insurance {
|
||||
@Prop() financialCeiling?: string;
|
||||
@Prop({ type: [String] })
|
||||
coverages?: string[];
|
||||
|
||||
/** CAR_BODY only: mocked car-body insurance inquiry result */
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
carBodyInsurance?: {
|
||||
policyNumber?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
insurerCompany?: string;
|
||||
coverages?: string[];
|
||||
};
|
||||
}
|
||||
export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
|
||||
|
||||
@@ -87,6 +97,13 @@ export class PartyStatement {
|
||||
|
||||
@Prop({ type: String })
|
||||
description?: string;
|
||||
|
||||
/** CAR_BODY: accident conditions from description step */
|
||||
@Prop() accidentDate?: Date;
|
||||
@Prop() accidentTime?: string;
|
||||
@Prop() weatherCondition?: string;
|
||||
@Prop() roadCondition?: string;
|
||||
@Prop() lightCondition?: string;
|
||||
}
|
||||
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
|
||||
|
||||
@@ -130,6 +147,13 @@ export class Party {
|
||||
@Prop({ type: PersonSchema })
|
||||
person: Person;
|
||||
|
||||
/**
|
||||
* CAR_BODY only: first form – accident with car vs object.
|
||||
* Second form (guilty/damaged) may be added later as carBodySecondForm.
|
||||
*/
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
/**
|
||||
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
|
||||
*/
|
||||
|
||||
@@ -249,6 +249,10 @@ export class SubmitReply {
|
||||
|
||||
@Prop()
|
||||
systemResponse: string;
|
||||
|
||||
/** Field expert profile at reply time (`expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export class ExpertResendParties {
|
||||
@@ -271,6 +275,10 @@ export class ActorLockDetails {
|
||||
|
||||
@Prop({ type: Types.ObjectId })
|
||||
actorId?: Types.ObjectId;
|
||||
|
||||
/** Field expert profile when lock was taken (`expert` collection). */
|
||||
@Prop({ type: Object })
|
||||
expertProfileSnapshot?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export enum CreationMethod {
|
||||
@@ -282,6 +290,7 @@ export enum CreationMethod {
|
||||
export enum FilledBy {
|
||||
CUSTOMER = "customer",
|
||||
EXPERT = "expert",
|
||||
REGISTRAR = "registrar",
|
||||
}
|
||||
|
||||
export class ExpertLinkInfo {
|
||||
@@ -449,6 +458,14 @@ export class RequestManagementModel {
|
||||
@Prop({ type: Boolean, default: false })
|
||||
isHandledStatsUpdated?: boolean;
|
||||
|
||||
/** Latest field expert profile when expert requested party resend (V1 `sendAgainRequest`). */
|
||||
@Prop({ type: Object })
|
||||
expertResendByExpertProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
/** Field expert profile when expert marked in-person visit (V1 `inPersonVisit`). */
|
||||
@Prop({ type: Object })
|
||||
fieldExpertInPersonVisitProfileSnapshot?: Record<string, string | undefined>;
|
||||
|
||||
// Expert-initiated file tracking
|
||||
@Prop({ type: Boolean, default: false })
|
||||
expertInitiated?: boolean;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { Types } from "mongoose";
|
||||
import { WorkflowStep } from "src/Types&Enums/blame-request-management/blameWorkflow-steps.enum";
|
||||
import {
|
||||
ExpertProfileSnapshot,
|
||||
ExpertProfileSnapshotSchema,
|
||||
} from "./expert-profile-snapshot.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ActorLock {
|
||||
@@ -13,6 +17,10 @@ export class ActorLock {
|
||||
|
||||
@Prop({ type: String, default: "expert" })
|
||||
actorRole: "expert" | "admin";
|
||||
|
||||
/** Field expert profile when lock was taken (`expert` collection). */
|
||||
@Prop({ type: ExpertProfileSnapshotSchema })
|
||||
expertProfileSnapshot?: ExpertProfileSnapshot;
|
||||
}
|
||||
export const ActorLockSchema = SchemaFactory.createForClass(ActorLock);
|
||||
|
||||
@@ -34,6 +42,10 @@ export class Workflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@Prop({ type: ActorLockSchema })
|
||||
lockedBy?: ActorLock;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Get,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from "@nestjs/common";
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
@@ -48,6 +46,7 @@ export class ExpertInitiatedController {
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Get("my-files")
|
||||
@ApiOperation({
|
||||
summary: "List my expert-initiated files",
|
||||
@@ -61,6 +60,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-blame-data/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit all blame-needed data",
|
||||
@@ -86,6 +86,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-claim-data/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Submit claim-needed data",
|
||||
@@ -111,6 +112,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary: "Create expert-initiated blame file",
|
||||
@@ -138,6 +140,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-form-third-party/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert completes all information for IN_PERSON THIRD_PARTY file",
|
||||
@@ -167,6 +170,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("complete-form-car-body/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert completes all information for IN_PERSON CAR_BODY file",
|
||||
@@ -196,6 +200,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-video/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert uploads video for expert-initiated file",
|
||||
@@ -252,6 +257,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("upload-voice/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert uploads voice for expert-initiated file",
|
||||
@@ -309,6 +315,7 @@ export class ExpertInitiatedController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Post("add-accident-fields/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "Expert adds accident fields to expert-initiated file",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user