forked from Yara724/api
Compare commits
43 Commits
integrate-
...
2898897dd9
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
2848
package-lock.json
generated
2848
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.22.1",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
export enum WorkflowStep {
|
export enum WorkflowStep {
|
||||||
|
|
||||||
CREATED = "CREATED",
|
CREATED = "CREATED",
|
||||||
|
|
||||||
|
// ---------- CAR_BODY only (no confession; accident type form) ----------
|
||||||
|
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
|
||||||
|
|
||||||
// ---------- First party ----------
|
// ---------- First party ----------
|
||||||
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
|
||||||
FIRST_VIDEO = "FIRST_VIDEO",
|
FIRST_VIDEO = "FIRST_VIDEO",
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
//! NEW
|
//! NEW
|
||||||
export enum CaseStatus {
|
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"
|
||||||
|
}
|
||||||
41
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
41
src/Types&Enums/blame-request-management/resend-item-ui.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ResendItemType } from "./resendItemType.enum";
|
||||||
|
|
||||||
|
/** 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[]) {
|
||||||
|
return requestedItems.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];
|
||||||
|
}
|
||||||
@@ -9,4 +9,7 @@ export enum ResendItemType {
|
|||||||
// Media evidence
|
// Media evidence
|
||||||
VOICE = "voice",
|
VOICE = "voice",
|
||||||
VIDEO = "video",
|
VIDEO = "video",
|
||||||
|
|
||||||
|
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||||
|
DESCRIPTION = "description",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export enum ClaimWorkflowStep {
|
|||||||
|
|
||||||
// Expert: Damage assessment
|
// Expert: Damage assessment
|
||||||
EXPERT_DAMAGE_ASSESSMENT = "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",
|
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
|
||||||
|
|
||||||
// Insurer approval
|
// Insurer approval
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export enum RoleEnum {
|
|||||||
EXPERT = "expert",
|
EXPERT = "expert",
|
||||||
DAMAGE_EXPERT = "damage_expert",
|
DAMAGE_EXPERT = "damage_expert",
|
||||||
FIELD_EXPERT = "field_expert",
|
FIELD_EXPERT = "field_expert",
|
||||||
|
REGISTRAR = "registrar",
|
||||||
COMPANY = "company",
|
COMPANY = "company",
|
||||||
ADMIN = "admin",
|
ADMIN = "admin",
|
||||||
USER = "user",
|
USER = "user",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
|
||||||
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.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 { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
|
||||||
|
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
|
||||||
import {
|
import {
|
||||||
GenuineRegisterDto,
|
GenuineRegisterDto,
|
||||||
InsurerRegisterDto,
|
InsurerRegisterDto,
|
||||||
@@ -64,6 +65,14 @@ export class ActorAuthController {
|
|||||||
return await this.actorAuthService.createFieldExpertMock(body);
|
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)
|
@UseGuards(LocalActorAuthGuard)
|
||||||
@Post("login")
|
@Post("login")
|
||||||
@Roles()
|
@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 { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||||
import { ExpertDbService } from "src/users/entities/db-service/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 { 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 { HashService } from "src/utils/hash/hash.service";
|
||||||
// import { MailService } from "src/utils/mail/mail.service";
|
// import { MailService } from "src/utils/mail/mail.service";
|
||||||
import { OtpService } from "src/utils/otp/otp.service";
|
import { OtpService } from "src/utils/otp/otp.service";
|
||||||
@@ -46,6 +47,7 @@ export class ActorAuthService {
|
|||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||||
|
private readonly registrarDbService: RegistrarDbService,
|
||||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||||
private readonly clientDbService: ClientDbService,
|
private readonly clientDbService: ClientDbService,
|
||||||
@@ -79,6 +81,13 @@ export class ActorAuthService {
|
|||||||
else
|
else
|
||||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
res = await this.fieldExpertDbService.findOne({ email: username });
|
||||||
break;
|
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:
|
case RoleEnum.COMPANY:
|
||||||
res = await this.insurerExpertDbService.findOne({ email: username });
|
res = await this.insurerExpertDbService.findOne({ email: username });
|
||||||
break;
|
break;
|
||||||
@@ -273,6 +282,10 @@ export class ActorAuthService {
|
|||||||
actor = await this.fieldExpertDbService.findOne(filter);
|
actor = await this.fieldExpertDbService.findOne(filter);
|
||||||
dbServiceToUpdate = this.fieldExpertDbService as any;
|
dbServiceToUpdate = this.fieldExpertDbService as any;
|
||||||
}
|
}
|
||||||
|
if (!actor) {
|
||||||
|
actor = await this.registrarDbService.findOne(filter);
|
||||||
|
dbServiceToUpdate = this.registrarDbService as any;
|
||||||
|
}
|
||||||
|
|
||||||
if (!actor) {
|
if (!actor) {
|
||||||
throw new NotFoundException("actor not found");
|
throw new NotFoundException("actor not found");
|
||||||
@@ -309,6 +322,10 @@ export class ActorAuthService {
|
|||||||
userExist = await this.fieldExpertDbService.findOne({ email });
|
userExist = await this.fieldExpertDbService.findOne({ email });
|
||||||
dbServiceToUpdate = this.fieldExpertDbService;
|
dbServiceToUpdate = this.fieldExpertDbService;
|
||||||
}
|
}
|
||||||
|
if (!userExist) {
|
||||||
|
userExist = await this.registrarDbService.findOne({ email });
|
||||||
|
dbServiceToUpdate = this.registrarDbService as any;
|
||||||
|
}
|
||||||
if (!userExist) throw new NotFoundException("user not found");
|
if (!userExist) throw new NotFoundException("user not found");
|
||||||
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
|
||||||
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
|
||||||
@@ -383,6 +400,7 @@ export class ActorAuthService {
|
|||||||
"phone",
|
"phone",
|
||||||
"mobile",
|
"mobile",
|
||||||
],
|
],
|
||||||
|
registrar: ["email"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedFields = allowedFieldsByRole[role];
|
const allowedFields = allowedFieldsByRole[role];
|
||||||
@@ -498,4 +516,34 @@ export class ActorAuthService {
|
|||||||
throw er;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
|||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
import { OtpService } from "src/utils/otp/otp.service";
|
import { OtpService } from "src/utils/otp/otp.service";
|
||||||
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
|
||||||
|
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
|
||||||
|
|
||||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -71,6 +72,12 @@ export class UserAuthService {
|
|||||||
});
|
});
|
||||||
const otp = this.otpCreator.create();
|
const otp = this.otpCreator.create();
|
||||||
const hashOtp = await this.hashService.hash(otp);
|
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) {
|
if (!userExist) {
|
||||||
await this.smsSender(otp, mobile);
|
await this.smsSender(otp, mobile);
|
||||||
/// create otp request
|
/// create otp request
|
||||||
@@ -90,9 +97,7 @@ export class UserAuthService {
|
|||||||
city: "",
|
city: "",
|
||||||
address: "",
|
address: "",
|
||||||
state: "",
|
state: "",
|
||||||
otpExpire: new Date(
|
otpExpire,
|
||||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
|
||||||
).getTime(),
|
|
||||||
});
|
});
|
||||||
return new LoginDtoRs(newUser);
|
return new LoginDtoRs(newUser);
|
||||||
}
|
}
|
||||||
@@ -105,9 +110,7 @@ export class UserAuthService {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
otp: hashOtp,
|
otp: hashOtp,
|
||||||
otpExpire: new Date(
|
otpExpire,
|
||||||
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
|
|
||||||
).getTime(),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (updateTokens) return new LoginDtoRs(userExist);
|
if (updateTokens) return new LoginDtoRs(userExist);
|
||||||
@@ -115,25 +118,26 @@ export class UserAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async smsSender(otp: string, mobile: string) {
|
private async smsSender(otp: string, mobile: string) {
|
||||||
return this.smsManagerService
|
try {
|
||||||
.verifyLookUp({
|
await this.smsManagerService.verifyLookUp({
|
||||||
token: otp,
|
token: otp,
|
||||||
template: process.env.AUTH_SMS_TEMPLATE,
|
template: process.env.AUTH_SMS_TEMPLATE,
|
||||||
receptor: mobile,
|
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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
|
||||||
|
);
|
||||||
|
if (err instanceof HttpException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new HttpException(
|
||||||
|
"auth sms send failed",
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.DAMAGE_EXPERT,
|
||||||
RoleEnum.COMPANY,
|
RoleEnum.COMPANY,
|
||||||
RoleEnum.FIELD_EXPERT,
|
RoleEnum.FIELD_EXPERT,
|
||||||
|
RoleEnum.REGISTRAR,
|
||||||
].includes(payload.role)
|
].includes(payload.role)
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException("User role is not authorized");
|
throw new UnauthorizedException("User role is not authorized");
|
||||||
|
|||||||
@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
|
|||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: `${process.env.SECRET}`,
|
secret: `${process.env.SECRET}`,
|
||||||
});
|
});
|
||||||
if (payload.role !== RoleEnum.USER) {
|
|
||||||
console.log(
|
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
|
||||||
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
|
|
||||||
request.user.role,
|
|
||||||
);
|
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
request.user = payload;
|
request.user = payload;
|
||||||
request.identity = request.user;
|
request.identity = request.user;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
|
|||||||
import { UsersModule } from "src/users/users.module";
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||||
|
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||||
@@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt";
|
|||||||
ClaimRequiredDocumentDbService,
|
ClaimRequiredDocumentDbService,
|
||||||
ClaimAccessGuard,
|
ClaimAccessGuard,
|
||||||
],
|
],
|
||||||
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
|
controllers: [
|
||||||
|
ClaimRequestManagementController,
|
||||||
|
ClaimRequestManagementV2Controller,
|
||||||
|
RegistrarClaimV1Controller,
|
||||||
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ClaimRequestManagementService,
|
ClaimRequestManagementService,
|
||||||
ClaimRequestManagementDbService,
|
ClaimRequestManagementDbService,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Get,
|
Get,
|
||||||
@@ -25,15 +26,21 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
|
|||||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-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 { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
|
||||||
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-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";
|
||||||
|
|
||||||
@ApiTags("claim-request-management (v2)")
|
@ApiTags("claim-request-management (v2)")
|
||||||
@Controller("v2/claim-request-management")
|
@Controller("v2/claim-request-management")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(GlobalGuard, RolesGuard)
|
@UseGuards(GlobalGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.USER)
|
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||||
export class ClaimRequestManagementV2Controller {
|
export class ClaimRequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
@@ -51,7 +58,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
})
|
})
|
||||||
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
|
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -91,6 +98,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
return await this.claimRequestManagementService.getClaimDetailsV2(
|
return await this.claimRequestManagementService.getClaimDetailsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -100,6 +108,89 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Post("create-from-blame/:blameRequestId")
|
@Post("create-from-blame/:blameRequestId")
|
||||||
@ApiParam({
|
@ApiParam({
|
||||||
name: "blameRequestId",
|
name: "blameRequestId",
|
||||||
@@ -231,6 +322,7 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -252,13 +344,14 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||||
|
|
||||||
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
|
||||||
|
Optional: upload car green card file in the same step.
|
||||||
|
|
||||||
**Validations:**
|
**Validations:**
|
||||||
- Claim must exist
|
- Claim must exist
|
||||||
- User must be the claim owner (damaged party from blame case)
|
- User must be the claim owner (damaged party from blame case)
|
||||||
- Current workflow step must be SELECT_OTHER_PARTS
|
- Current workflow step must be SELECT_OTHER_PARTS
|
||||||
- Bank information must not have been submitted previously
|
- 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
|
- National code must be exactly 10 digits
|
||||||
|
|
||||||
**Valid Other Parts (Optional):**
|
**Valid Other Parts (Optional):**
|
||||||
@@ -276,42 +369,38 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
description: "The claim case ID (MongoDB ObjectId)",
|
description: "The claim case ID (MongoDB ObjectId)",
|
||||||
example: "507f1f77bcf86cd799439011",
|
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({
|
@ApiBody({
|
||||||
type: SelectOtherPartsV2Dto,
|
description:
|
||||||
description: "Other parts selection and bank information",
|
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
|
||||||
examples: {
|
schema: {
|
||||||
example1: {
|
type: "object",
|
||||||
summary: "Only bank info (no other parts damaged)",
|
properties: {
|
||||||
value: {
|
otherParts: {
|
||||||
otherParts: [],
|
oneOf: [
|
||||||
shebaNumber: "123456789012345678901234",
|
{ type: "array", items: { type: "string" } },
|
||||||
nationalCodeOfOwner: "1234567890",
|
{ type: "string", description: "JSON string array for multipart" },
|
||||||
},
|
],
|
||||||
},
|
example: ["engine", "suspension"],
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
|
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||||
|
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||||
|
file: { type: "string", format: "binary" },
|
||||||
},
|
},
|
||||||
|
required: ["sheba", "nationalCodeOfInsurer"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -339,12 +428,15 @@ export class ClaimRequestManagementV2Controller {
|
|||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@Body() body: SelectOtherPartsV2Dto,
|
@Body() body: SelectOtherPartsV2Dto,
|
||||||
@CurrentUser() user: any,
|
@CurrentUser() user: any,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||||
try {
|
try {
|
||||||
return await this.claimRequestManagementService.selectOtherPartsV2(
|
return await this.claimRequestManagementService.selectOtherPartsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
body,
|
body,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
|
file,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -397,6 +489,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
return await this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -434,16 +527,16 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
description: `
|
description: `
|
||||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
|
||||||
|
|
||||||
**Upload one of the 13 required documents:**
|
**Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||||
- car_green_card
|
- car_green_card
|
||||||
- damaged_driving_license_front/back
|
- damaged_driving_license_front/back
|
||||||
- damaged_chassis_number, damaged_engine_photo
|
- damaged_chassis_number, damaged_engine_photo
|
||||||
- damaged_car_card_front/back, damaged_metal_plate
|
- damaged_car_card_front/back, damaged_metal_plate
|
||||||
- guilty_driving_license_front/back
|
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||||
- guilty_car_card_front/back, guilty_metal_plate
|
|
||||||
|
|
||||||
**When all 13 documents are uploaded:**
|
**When all required documents are uploaded:** Workflow moves to CAPTURE_PART_DAMAGES (Step 5).
|
||||||
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
|
|
||||||
|
**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({
|
@ApiParam({
|
||||||
@@ -507,6 +600,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
body,
|
body,
|
||||||
file,
|
file,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -547,6 +641,8 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||||
|
|
||||||
**All captures must be completed before user can submit claim.**
|
**All captures must be completed before user can submit claim.**
|
||||||
|
|
||||||
|
**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({
|
@ApiParam({
|
||||||
@@ -598,6 +694,7 @@ Returns status of each item (uploaded/captured or not).
|
|||||||
body,
|
body,
|
||||||
file,
|
file,
|
||||||
user.sub,
|
user.sub,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -606,4 +703,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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,3 +79,26 @@ export class CapturePartV2ResponseDto {
|
|||||||
})
|
})
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export class ClaimDetailsV2ResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
|
||||||
|
userRating?: {
|
||||||
|
progressSpeed: number;
|
||||||
|
registrationEase: number;
|
||||||
|
overallEvaluation: number;
|
||||||
|
comment?: string;
|
||||||
|
createdAt?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiProperty({ description: 'Created at' })
|
@ApiProperty({ description: 'Created at' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
|||||||
@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
|
|||||||
otherParts?: OtherCarPart[];
|
otherParts?: OtherCarPart[];
|
||||||
|
|
||||||
@ApiProperty({
|
@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',
|
example: '123456789012345678901234',
|
||||||
pattern: '^[0-9]{24}$',
|
|
||||||
minLength: 24,
|
|
||||||
maxLength: 24,
|
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'Sheba number is required' })
|
@IsOptional()
|
||||||
@IsString({ message: 'Sheba number must be a string' })
|
@IsString()
|
||||||
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
|
shebaNumber?: string;
|
||||||
@Matches(/^[0-9]{24}$/, {
|
|
||||||
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
|
|
||||||
})
|
|
||||||
shebaNumber: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'National code of the owner - 10 digits',
|
description: 'National code of insurer/owner - 10 digits',
|
||||||
example: '1234567890',
|
example: '1234567890',
|
||||||
pattern: '^[0-9]{10}$',
|
pattern: '^[0-9]{10}$',
|
||||||
minLength: 10,
|
minLength: 10,
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
})
|
})
|
||||||
@IsNotEmpty({ message: 'National code of owner is required' })
|
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
|
||||||
@IsString({ message: 'National code must be a string' })
|
@IsString({ message: 'National code must be a string' })
|
||||||
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
|
||||||
@Matches(/^[0-9]{10}$/, {
|
@Matches(/^[0-9]{10}$/, {
|
||||||
message: 'National code must contain exactly 10 digits',
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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 });
|
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(
|
async find(
|
||||||
filter: FilterQuery<ClaimCase>,
|
filter: FilterQuery<ClaimCase>,
|
||||||
options?: { lean?: boolean; select?: string },
|
options?: { lean?: boolean; select?: string },
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ export class ClaimExpertReply {
|
|||||||
export const ClaimExpertReplySchema =
|
export const ClaimExpertReplySchema =
|
||||||
SchemaFactory.createForClass(ClaimExpertReply);
|
SchemaFactory.createForClass(ClaimExpertReply);
|
||||||
|
|
||||||
|
/** One line item the user disputes on an expert-priced part */
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class ClaimUserObjection {
|
export class ClaimUserObjectionPart {
|
||||||
@Prop({ type: String, required: true })
|
@Prop({ type: String, required: true })
|
||||||
partId: string;
|
partId: string;
|
||||||
|
|
||||||
@@ -103,9 +104,47 @@ export class ClaimUserObjection {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
typeOfDamage?: string;
|
typeOfDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
carPartDamage?: string;
|
||||||
|
|
||||||
|
@Prop({ type: String })
|
||||||
|
side?: string;
|
||||||
}
|
}
|
||||||
export const ClaimUserObjectionSchema =
|
export const ClaimUserObjectionPartSchema =
|
||||||
SchemaFactory.createForClass(ClaimUserObjection);
|
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 })
|
@Schema({ _id: false })
|
||||||
export class ClaimResendRequest {
|
export class ClaimResendRequest {
|
||||||
@@ -182,8 +221,8 @@ export class ClaimEvaluation {
|
|||||||
@Prop({ type: ClaimResendRequestSchema })
|
@Prop({ type: ClaimResendRequestSchema })
|
||||||
damageExpertResend?: ClaimResendRequest | null;
|
damageExpertResend?: ClaimResendRequest | null;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserObjectionSchema })
|
@Prop({ type: ClaimUserObjectionPayloadSchema })
|
||||||
objection?: ClaimUserObjection;
|
objection?: ClaimUserObjectionPayload;
|
||||||
|
|
||||||
@Prop({ type: ClaimUserResendPayloadSchema })
|
@Prop({ type: ClaimUserResendPayloadSchema })
|
||||||
userResendDocuments?: ClaimUserResendPayload;
|
userResendDocuments?: ClaimUserResendPayload;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ClaimCaseSnapshotSchema,
|
ClaimCaseSnapshotSchema,
|
||||||
} from "./claim-case.snapshot.schema";
|
} from "./claim-case.snapshot.schema";
|
||||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||||
|
import { UserClaimRating } from "./claim-request-management.schema";
|
||||||
|
|
||||||
@Schema({ _id: false })
|
@Schema({ _id: false })
|
||||||
export class RequiredDocumentRef {
|
export class RequiredDocumentRef {
|
||||||
@@ -75,6 +76,12 @@ export class ClaimCase {
|
|||||||
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
|
||||||
claimStatus: ClaimStatus;
|
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.
|
* Link to the blame case that originated this claim.
|
||||||
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
|
||||||
@@ -133,6 +140,15 @@ export class ClaimCase {
|
|||||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||||
history?: HistoryEvent[];
|
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.
|
* Legacy fields kept optional to simplify progressive migration.
|
||||||
* If you choose to migrate later, we can remove these.
|
* If you choose to migrate later, we can remove these.
|
||||||
|
|||||||
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,6 +7,11 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
assertBlameCaseForExpertTenant,
|
||||||
|
blameCaseAccessibleToExpert,
|
||||||
|
requireActorClientKey,
|
||||||
|
} from "src/helpers/tenant-scope";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +30,7 @@ import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatu
|
|||||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
import { ResendItemType } from "src/Types&Enums/blame-request-management/resendItemType.enum";
|
||||||
|
import { buildResendItemsWithUi } from "src/Types&Enums/blame-request-management/resend-item-ui";
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
import { buildFileLink } from "src/helpers/urlCreator";
|
||||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||||
import { ResendRequestDto } from "./dto/resend.dto";
|
import { ResendRequestDto } from "./dto/resend.dto";
|
||||||
@@ -32,6 +38,7 @@ import { readFile } from "fs/promises";
|
|||||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
||||||
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
|
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
|
||||||
|
import { RequestManagementService } from "src/request-management/request-management.service";
|
||||||
|
|
||||||
interface CheckedRequestEntry {
|
interface CheckedRequestEntry {
|
||||||
CheckedRequest?: {
|
CheckedRequest?: {
|
||||||
@@ -58,6 +65,10 @@ function statementToFormKey(
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertBlameService {
|
export class ExpertBlameService {
|
||||||
private readonly logger = new Logger(ExpertBlameService.name);
|
private readonly logger = new Logger(ExpertBlameService.name);
|
||||||
|
|
||||||
|
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
|
||||||
|
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementDbService: RequestManagementDbService,
|
private readonly requestManagementDbService: RequestManagementDbService,
|
||||||
private readonly blameRequestDbService: BlameRequestDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
@@ -67,7 +78,8 @@ export class ExpertBlameService {
|
|||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
private readonly blameDocumentDbService: BlameDocumentDbService,
|
||||||
private readonly userSignDbService: UserSignDbService,
|
private readonly userSignDbService: UserSignDbService,
|
||||||
) {}
|
private readonly requestManagementService: RequestManagementService,
|
||||||
|
) { }
|
||||||
|
|
||||||
async findAll(actor: any): Promise<AllRequestDtoRs> {
|
async findAll(actor: any): Promise<AllRequestDtoRs> {
|
||||||
// 1. Fetch all potentially relevant requests from the database.
|
// 1. Fetch all potentially relevant requests from the database.
|
||||||
@@ -185,8 +197,9 @@ export class ExpertBlameService {
|
|||||||
*/
|
*/
|
||||||
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
|
||||||
try {
|
try {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const expertId = actor.sub;
|
const expertId = actor.sub;
|
||||||
|
|
||||||
// Fetch all DISAGREEMENT cases
|
// Fetch all DISAGREEMENT cases
|
||||||
const allCases = await this.blameRequestDbService.find(
|
const allCases = await this.blameRequestDbService.find(
|
||||||
{
|
{
|
||||||
@@ -196,9 +209,25 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Filter to show only:
|
// Filter to show only:
|
||||||
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
|
||||||
// 2. Requests decided by current expert
|
// 2. Fresh requests (WAITING_FOR_EXPERT and no decision)
|
||||||
|
// 3. Requests decided by current expert
|
||||||
|
// 4. Expert-initiated: only the initiating field expert sees them
|
||||||
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
|
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
|
||||||
|
if (!blameCaseAccessibleToExpert(doc, actor)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertInitiated = doc.expertInitiated === true;
|
||||||
|
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||||
|
|
||||||
|
if (expertInitiated && initiatedByFieldExpertId) {
|
||||||
|
if (String(initiatedByFieldExpertId) !== expertId) {
|
||||||
|
return false; // Only the initiating field expert can see this file
|
||||||
|
}
|
||||||
|
return true; // Initiating expert can see their expert-initiated file
|
||||||
|
}
|
||||||
|
|
||||||
const status = doc.status as string;
|
const status = doc.status as string;
|
||||||
const decision = doc.expert as any;
|
const decision = doc.expert as any;
|
||||||
const decidedByExpertId = decision?.decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decision?.decidedByExpertId;
|
||||||
@@ -223,6 +252,35 @@ export class ExpertBlameService {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const staleIds = new Set<string>();
|
||||||
|
for (const doc of visibleCases) {
|
||||||
|
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||||
|
if (!w?.locked) continue;
|
||||||
|
const la = w.lockedAt;
|
||||||
|
if (!la) {
|
||||||
|
staleIds.add(String(doc._id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Date.now() >=
|
||||||
|
new Date(la as string | Date).getTime() + this.blameV2LockTtlMs
|
||||||
|
) {
|
||||||
|
staleIds.add(String(doc._id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
[...staleIds].map((id) => this.expireBlameCaseWorkflowLockV2IfStale(id)),
|
||||||
|
);
|
||||||
|
for (const doc of visibleCases) {
|
||||||
|
if (!staleIds.has(String(doc._id))) continue;
|
||||||
|
const w = doc.workflow as Record<string, unknown>;
|
||||||
|
if (w) {
|
||||||
|
w.locked = false;
|
||||||
|
delete w.lockedAt;
|
||||||
|
delete w.lockedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const items: AllRequestDtoV2[] = visibleCases.map(
|
const items: AllRequestDtoV2[] = visibleCases.map(
|
||||||
(doc) => this.mapBlameRequestToListItemV2(doc),
|
(doc) => this.mapBlameRequestToListItemV2(doc),
|
||||||
);
|
);
|
||||||
@@ -278,6 +336,75 @@ export class ExpertBlameService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
||||||
|
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
||||||
|
*/
|
||||||
|
private async expireBlameCaseWorkflowLockV2IfStale(
|
||||||
|
requestId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
if (!request?.workflow?.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedAt = request.workflow.lockedAt;
|
||||||
|
if (
|
||||||
|
lockedAt &&
|
||||||
|
Date.now() <
|
||||||
|
new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedById = request.workflow.lockedBy?.actorId;
|
||||||
|
const filter: Record<string, unknown> = {
|
||||||
|
_id: new Types.ObjectId(requestId),
|
||||||
|
"workflow.locked": true,
|
||||||
|
};
|
||||||
|
if (lockedAt) {
|
||||||
|
filter["workflow.lockedAt"] = lockedAt;
|
||||||
|
} else if (lockedById) {
|
||||||
|
filter["workflow.lockedBy.actorId"] = new Types.ObjectId(
|
||||||
|
String(lockedById),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleared = await this.blameRequestDbService.findOneAndUpdate(
|
||||||
|
filter as any,
|
||||||
|
{
|
||||||
|
$set: { "workflow.locked": false },
|
||||||
|
$unset: { "workflow.lockedAt": "", "workflow.lockedBy": "" },
|
||||||
|
},
|
||||||
|
{ new: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cleared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.expert?.decision && lockedById) {
|
||||||
|
const rid = new Types.ObjectId(requestId).toString();
|
||||||
|
try {
|
||||||
|
await this.expertDbService.findOneAndUpdate(
|
||||||
|
{ _id: new Types.ObjectId(String(lockedById)) },
|
||||||
|
{
|
||||||
|
$inc: { "requestStats.totalChecked": -1 },
|
||||||
|
$pull: { countedRequests: rid } as any,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.warn(
|
||||||
|
`Blame case ${requestId} workflow lock expired; unlocked in DB and rolled back checked stat for expert ${lockedById}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Expert stat rollback failed after blame lock expiry ${requestId}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public unlockApi(request, timer) {
|
public unlockApi(request, timer) {
|
||||||
return setTimeout(async () => {
|
return setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -394,9 +521,9 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
// 2. Initial validation to ensure the expert has access
|
// 2. Initial validation to ensure the expert has access
|
||||||
// Check if locked by current expert and lock is still active
|
// Check if locked by current expert and lock is still active
|
||||||
const isLockedByCurrentExpert =
|
const isLockedByCurrentExpert =
|
||||||
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
|
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
|
||||||
|
|
||||||
// Check if lock has expired
|
// Check if lock has expired
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
@@ -404,7 +531,7 @@ export class ExpertBlameService {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
isLockExpired = now >= unlockTime;
|
isLockExpired = now >= unlockTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLockedByCurrentExpert && !isLockExpired) {
|
if (isLockedByCurrentExpert && !isLockExpired) {
|
||||||
// This is the correct expert, and the file is locked to them, which is fine.
|
// This is the correct expert, and the file is locked to them, which is fine.
|
||||||
// They can access it even if they closed the browser and came back.
|
// They can access it even if they closed the browser and came back.
|
||||||
@@ -415,7 +542,7 @@ export class ExpertBlameService {
|
|||||||
// The file is locked by someone else, or lock expired but status hasn't updated yet
|
// The file is locked by someone else, or lock expired but status hasn't updated yet
|
||||||
// Only block if lock is still active and not by current expert
|
// Only block if lock is still active and not by current expert
|
||||||
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
|
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
|
||||||
throw new BadRequestException("Request is locked by another expert");
|
throw new BadRequestException("Request is locked by another expert");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,12 +636,15 @@ export class ExpertBlameService {
|
|||||||
* Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence.
|
* Excludes history. Returns only non–CAR_BODY types. Builds file links for all evidence.
|
||||||
* Access control: Only allows viewing fresh requests or requests decided by current expert.
|
* Access control: Only allows viewing fresh requests or requests decided by current expert.
|
||||||
*/
|
*/
|
||||||
async findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
|
async findOneV2(requestId: string, actor: any): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
assertBlameCaseForExpertTenant(doc, actor);
|
||||||
const type = doc.type as string;
|
const type = doc.type as string;
|
||||||
if (type === BlameRequestType.CAR_BODY) {
|
if (type === BlameRequestType.CAR_BODY) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -522,81 +652,130 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access control: Check if expert has permission to view this request
|
// Access control
|
||||||
|
const expertInitiated = doc.expertInitiated === true;
|
||||||
|
const initiatedByFieldExpertId = doc.initiatedByFieldExpertId;
|
||||||
|
if (expertInitiated && initiatedByFieldExpertId && String(initiatedByFieldExpertId) !== actorId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only the field expert who created this file can view and review it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const decision = (doc.expert as any)?.decision;
|
const decision = (doc.expert as any)?.decision;
|
||||||
const decidedByExpertId = decision?.decidedByExpertId;
|
const decidedByExpertId = decision?.decidedByExpertId;
|
||||||
|
|
||||||
// Allow if:
|
|
||||||
// 1. No decision yet (fresh request)
|
|
||||||
// 2. Decision made by current expert
|
|
||||||
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"You do not have permission to view this request. It has been handled by another expert.",
|
"You do not have permission to view this request. It has been handled by another expert.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parties = (doc.parties ?? []) as Array<{
|
const parties = (doc.parties ?? []);
|
||||||
evidence?: { videoId?: string; voices?: string[] };
|
|
||||||
[key: string]: unknown;
|
const typedParties = parties as Array<{
|
||||||
|
vehicle?: {
|
||||||
|
inquiry?: {
|
||||||
|
mapped?: any; // Use 'any' or a more specific type if known
|
||||||
|
// other inquiry properties
|
||||||
|
};
|
||||||
|
// other vehicle properties
|
||||||
|
};
|
||||||
|
evidence?: { // For the second part of your original error description
|
||||||
|
videoId?: string | number;
|
||||||
|
voices?: (string | number)[];
|
||||||
|
videoUrl?: string;
|
||||||
|
voiceUrls?: string[];
|
||||||
|
};
|
||||||
|
// other party properties
|
||||||
}>;
|
}>;
|
||||||
for (const party of parties) {
|
|
||||||
|
// Extract inquiry.mapped for each party if needed, but we will keep the full vehicle object.
|
||||||
|
for (const party of typedParties) {
|
||||||
|
const mapped = party?.vehicle?.inquiry?.mapped;
|
||||||
|
// If you still want this field, you can add it like:
|
||||||
|
// party.inquiryMapped = mapped ?? null;
|
||||||
|
// But based on your last request, we are keeping the full vehicle object.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evidence (videos + voices)
|
||||||
|
for (const party of typedParties) {
|
||||||
if (!party.evidence) continue;
|
if (!party.evidence) continue;
|
||||||
const evidence = party.evidence as Record<string, unknown>;
|
const evidence = party.evidence as Record<string, unknown>;
|
||||||
|
|
||||||
if (evidence.videoId) {
|
if (evidence.videoId) {
|
||||||
const videoDoc = await this.blameVideoDbService.findById(
|
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId));
|
||||||
String(evidence.videoId),
|
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
|
||||||
);
|
|
||||||
if (videoDoc?.path) {
|
|
||||||
evidence.videoUrl = buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evidence.voices && Array.isArray(evidence.voices)) {
|
if (evidence.voices && Array.isArray(evidence.voices)) {
|
||||||
const voiceUrls: string[] = [];
|
const voiceUrls: string[] = [];
|
||||||
for (const voiceId of evidence.voices) {
|
for (const voiceId of evidence.voices) {
|
||||||
const voiceDoc = await this.blameVoiceDbService.findById(
|
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId));
|
||||||
String(voiceId),
|
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
|
||||||
);
|
|
||||||
if (voiceDoc?.path) {
|
|
||||||
voiceUrls.push(buildFileLink(voiceDoc.path));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
evidence.voiceUrls = voiceUrls;
|
evidence.voiceUrls = voiceUrls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date();
|
// Date formatting
|
||||||
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date();
|
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | number) : new Date();
|
||||||
|
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | number) : new Date();
|
||||||
|
|
||||||
|
|
||||||
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
const [createdDate, createdTime] = toJalaliDateAndTime(createdAt);
|
||||||
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
const [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
|
||||||
|
|
||||||
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
|
||||||
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
|
||||||
|
|
||||||
// Exclude mapped inquiry vehicle for both parties from response
|
// The line `delete party.vehicle;` has been removed, so vehicle will be included.
|
||||||
for (const party of parties) {
|
|
||||||
delete party.vehicle;
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
"findOneV2 failed",
|
"findOneV2 failed",
|
||||||
requestId,
|
requestId,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
error instanceof Error ? error.message : "Failed to get blame case details",
|
error instanceof Error ? error.message : "Failed to get blame case details",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async lockRequest(requestId: string, actorDetail) {
|
async lockRequest(requestId: string, actorDetail) {
|
||||||
|
const existing = await this.requestManagementDbService.findOne(requestId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException("Request not found");
|
||||||
|
}
|
||||||
|
if (existing.blameStatus === ReqBlameStatus.UserPending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Cannot lock request because blameStatus is UserPending",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocked = !!existing.lockFile;
|
||||||
|
const lockedByCurrent =
|
||||||
|
String(existing?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||||||
|
const isLockExpired =
|
||||||
|
!!existing.unlockTime && Date.now() >= new Date(existing.unlockTime).getTime();
|
||||||
|
|
||||||
|
// Idempotent behavior: same expert can re-enter their locked file.
|
||||||
|
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||||||
|
return { _id: requestId, lock: true, message: "Already locked by you" };
|
||||||
|
}
|
||||||
|
if (isLocked && !lockedByCurrent && !isLockExpired) {
|
||||||
|
throw new BadRequestException("Request is locked by another expert");
|
||||||
|
}
|
||||||
|
|
||||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
||||||
|
|
||||||
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
_id: requestId,
|
_id: requestId,
|
||||||
lockFile: false,
|
|
||||||
blameStatus: { $ne: ReqBlameStatus.UserPending },
|
blameStatus: { $ne: ReqBlameStatus.UserPending },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -642,14 +821,16 @@ export class ExpertBlameService {
|
|||||||
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
|
|
||||||
|
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
// First, check the current state
|
// First, check the current state
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actorDetail);
|
||||||
|
|
||||||
// Validate request is available for expert review
|
// Validate request is available for expert review
|
||||||
if (
|
if (
|
||||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
||||||
@@ -660,14 +841,28 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expert-initiated: only the initiating field expert can lock and review
|
||||||
|
if (
|
||||||
|
request.expertInitiated &&
|
||||||
|
request.initiatedByFieldExpertId &&
|
||||||
|
String(request.initiatedByFieldExpertId) !== actorDetail.sub
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Only the field expert who created this file can lock and review it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if locked and not expired
|
// Check if locked and not expired
|
||||||
if (request.workflow?.locked) {
|
if (request.workflow?.locked) {
|
||||||
const lockedAt = request.workflow.lockedAt;
|
const lockedAt = request.workflow.lockedAt;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
if (Date.now() < lockExpiryTime) {
|
if (Date.now() < lockExpiryTime) {
|
||||||
// Lock is still valid
|
// Lock is still valid
|
||||||
const lockedByActorId = String(request.workflow.lockedBy?.actorId);
|
const lockedByActorId = String(
|
||||||
|
request.workflow.lockedBy?.actorId ?? "",
|
||||||
|
);
|
||||||
if (lockedByActorId === actorDetail.sub) {
|
if (lockedByActorId === actorDetail.sub) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"You have already locked this request",
|
"You have already locked this request",
|
||||||
@@ -726,15 +921,30 @@ export class ExpertBlameService {
|
|||||||
async resendRequestV2(
|
async resendRequestV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
resendDto: ResendRequestDto,
|
resendDto: ResendRequestDto,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<{ requestId: string; status: string }> {
|
): Promise<{
|
||||||
|
requestId: string;
|
||||||
|
status: string;
|
||||||
|
parties: Array<{
|
||||||
|
partyId: string;
|
||||||
|
requestedItems: ResendItemType[];
|
||||||
|
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
// Validate request is locked by current expert
|
// Validate request is locked by current expert
|
||||||
if (!request.workflow?.locked) {
|
if (!request.workflow?.locked) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -752,7 +962,8 @@ export class ExpertBlameService {
|
|||||||
// Validate lock hasn't expired
|
// Validate lock hasn't expired
|
||||||
const lockedAt = request.workflow?.lockedAt;
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
if (Date.now() > lockExpiryTime) {
|
if (Date.now() > lockExpiryTime) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"Your lock time has expired. Please lock the request again.",
|
"Your lock time has expired. Please lock the request again.",
|
||||||
@@ -774,16 +985,42 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedItems = new Set<string>(Object.values(ResendItemType));
|
||||||
|
const partyIdsOnFile = new Set(
|
||||||
|
(request.parties || [])
|
||||||
|
.map((p) => (p.person?.userId ? String(p.person.userId) : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const party of resendDto.parties) {
|
||||||
|
const uid = String(party.partyId);
|
||||||
|
if (!partyIdsOnFile.has(uid)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`partyId ${uid} is not a party userId on this blame request.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const item of party.requestedItems || []) {
|
||||||
|
if (!allowedItems.has(String(item))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid requestedItems value: ${item}. Use ResendItemType values.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// Build resend requests for parties
|
const partyResendRequests = resendDto.parties.map((party) => {
|
||||||
const partyResendRequests = resendDto.parties.map((party) => ({
|
const uid = String(party.partyId);
|
||||||
partyId: new Types.ObjectId(String(party.partyId)),
|
return {
|
||||||
requestedItems: party.requestedItems,
|
partyId: new Types.ObjectId(uid),
|
||||||
description: party.description || "",
|
requestedItems: party.requestedItems,
|
||||||
requestedAt: now,
|
description: party.description || "",
|
||||||
completed: false,
|
requestedAt: now,
|
||||||
}));
|
completed: false,
|
||||||
|
uploadedDocuments: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
$set: {
|
$set: {
|
||||||
@@ -797,9 +1034,6 @@ export class ExpertBlameService {
|
|||||||
},
|
},
|
||||||
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
||||||
},
|
},
|
||||||
$push: {
|
|
||||||
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
|
|
||||||
},
|
|
||||||
$unset: {
|
$unset: {
|
||||||
"workflow.lockedAt": "",
|
"workflow.lockedAt": "",
|
||||||
"workflow.lockedBy": "",
|
"workflow.lockedBy": "",
|
||||||
@@ -817,11 +1051,21 @@ export class ExpertBlameService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
|
||||||
|
requestId,
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Send notifications to parties (SMS/Push) about required documents
|
// TODO: Send notifications to parties (SMS/Push) about required documents
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: String(request._id),
|
requestId: String(request._id),
|
||||||
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
|
||||||
|
parties: partyResendRequests.map((p) => ({
|
||||||
|
partyId: String(p.partyId),
|
||||||
|
requestedItems: p.requestedItems as ResendItemType[],
|
||||||
|
requestedItemsUi: buildResendItemsWithUi(p.requestedItems as string[]),
|
||||||
|
description: p.description || undefined,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -839,19 +1083,30 @@ export class ExpertBlameService {
|
|||||||
/**
|
/**
|
||||||
* V2: Submit expert reply for blame case (blameCases collection).
|
* V2: Submit expert reply for blame case (blameCases collection).
|
||||||
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
|
* Validates lock ownership and expiry, stores decision, unlocks, moves to WAITING_FOR_SIGNATURES.
|
||||||
|
*
|
||||||
|
* Note: V1 `replyRequest` also handled an “objection” path (`expertResendReply`) by writing
|
||||||
|
* `expertSubmitReplyFinal` on the legacy request document. Blame V2 uses a single
|
||||||
|
* `expert.decision` on `blameCases`; resend is modeled as `expert.resend` *before* the first
|
||||||
|
* decision. If you need a second decision after signatures/objections, extend the schema
|
||||||
|
* (e.g. decision revision) and branch here similarly to V1.
|
||||||
*/
|
*/
|
||||||
async replyRequestV2(
|
async replyRequestV2(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
reply: SubmitReplyDto,
|
reply: SubmitReplyDto,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<{ requestId: string; status: string }> {
|
): Promise<{ requestId: string; status: string }> {
|
||||||
try {
|
try {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
const request = await this.blameRequestDbService.findById(requestId);
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new NotFoundException("Request not found");
|
throw new NotFoundException("Request not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertBlameCaseForExpertTenant(request, actor);
|
||||||
|
|
||||||
// Validate no decision exists yet
|
// Validate no decision exists yet
|
||||||
if (request.expert?.decision) {
|
if (request.expert?.decision) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -873,7 +1128,8 @@ export class ExpertBlameService {
|
|||||||
// Check if lock has expired (15 minutes)
|
// Check if lock has expired (15 minutes)
|
||||||
let isLockExpired = false;
|
let isLockExpired = false;
|
||||||
if (lockedAt) {
|
if (lockedAt) {
|
||||||
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
isLockExpired = Date.now() > lockExpiryTime;
|
isLockExpired = Date.now() > lockExpiryTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,6 +1224,126 @@ export class ExpertBlameService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async submitInPerson(
|
||||||
|
requestId: string,
|
||||||
|
actorId: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||||
|
const request = await this.blameRequestDbService.findById(requestId);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new NotFoundException("Request not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate no decision exists yet
|
||||||
|
if (request.expert?.decision) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This request already has an expert decision.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if request is locked
|
||||||
|
if (!request.workflow?.locked) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You must lock the request before submitting a reply.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedByActorId = String(request.workflow?.lockedBy?.actorId || "");
|
||||||
|
const lockedAt = request.workflow?.lockedAt;
|
||||||
|
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||||
|
|
||||||
|
// Check if lock has expired (15 minutes)
|
||||||
|
let isLockExpired = false;
|
||||||
|
if (lockedAt) {
|
||||||
|
const lockExpiryTime =
|
||||||
|
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||||
|
isLockExpired = Date.now() > lockExpiryTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different lock scenarios
|
||||||
|
if (!isLockedByCurrentActor) {
|
||||||
|
// Request is locked by another expert
|
||||||
|
if (isLockExpired) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You must lock the request first before submitting a reply.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"This request is currently locked by another expert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current actor is the lock owner
|
||||||
|
if (isLockExpired) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Your lock time has expired. Please lock the request again before submitting.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Build expert decision with fields
|
||||||
|
const decisionPayload: any = {
|
||||||
|
guiltyPartyId: null,
|
||||||
|
description: "حضوری مراجعه شود.",
|
||||||
|
decidedAt: now,
|
||||||
|
decidedByExpertId: new Types.ObjectId(actorId),
|
||||||
|
fields: {
|
||||||
|
accidentWay: null,
|
||||||
|
accidentReason: null,
|
||||||
|
accidentType: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePayload = {
|
||||||
|
$set: {
|
||||||
|
"workflow.locked": false,
|
||||||
|
"workflow.currentStep": "COMPLETED",
|
||||||
|
"workflow.nextStep": null,
|
||||||
|
"expert.decision": decisionPayload,
|
||||||
|
status: CaseStatus.STOPPED,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
|
||||||
|
},
|
||||||
|
$unset: {
|
||||||
|
"workflow.lockedAt": "",
|
||||||
|
"workflow.lockedBy": "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResult = await this.blameRequestDbService.findByIdAndUpdate(
|
||||||
|
requestId,
|
||||||
|
updatePayload,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateResult) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
"Failed to update request with expert reply",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId: String(request._id),
|
||||||
|
status: CaseStatus.WAITING_FOR_SIGNATURES,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
"submitInPerson failed",
|
||||||
|
requestId,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
error instanceof Error ? error.message : "Failed to submit expert reply",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async updateDamageExpertStats(
|
private async updateDamageExpertStats(
|
||||||
expertId: string,
|
expertId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -1040,7 +1416,7 @@ export class ExpertBlameService {
|
|||||||
"Access denied to this request. You are not the locked expert.",
|
"Access denied to this request. You are not the locked expert.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired (unlockTime has passed)
|
// Check if lock has expired (unlockTime has passed)
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
@@ -1051,7 +1427,7 @@ export class ExpertBlameService {
|
|||||||
} else if (request.unlockTime == null) {
|
} else if (request.unlockTime == null) {
|
||||||
throw new ForbiddenException("Your lock time has expired.");
|
throw new ForbiddenException("Your lock time has expired.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.lockFile) {
|
if (!request.lockFile) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"You must lock the request before submitting a reply.",
|
"You must lock the request before submitting a reply.",
|
||||||
@@ -1302,4 +1678,5 @@ export class ExpertBlameService {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import { ResendRequestDto } from "./dto/resend.dto";
|
|||||||
@Controller("v2/expert-blame")
|
@Controller("v2/expert-blame")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.EXPERT)
|
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
|
||||||
export class ExpertBlameV2Controller {
|
export class ExpertBlameV2Controller {
|
||||||
constructor(private readonly expertBlameService: ExpertBlameService) {}
|
constructor(private readonly expertBlameService: ExpertBlameService) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async findAll(@CurrentUser() actor: any) {
|
async findAll(@CurrentUser() actor: any) {
|
||||||
@@ -42,7 +42,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@ApiParam({ name: "id", description: "Blame case request id" })
|
@ApiParam({ name: "id", description: "Blame case request id" })
|
||||||
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.findOneV2(id, actor.sub);
|
return await this.expertBlameService.findOneV2(id, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -73,7 +73,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.resendRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -93,7 +93,7 @@ export class ExpertBlameV2Controller {
|
|||||||
@CurrentUser() actor: any,
|
@CurrentUser() actor: any,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
|
return await this.expertBlameService.replyRequestV2(id, body, actor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
@@ -101,4 +101,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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ export class ClaimDetailV2ResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
@ApiPropertyOptional({ description: 'Damaged parts captured' })
|
||||||
damagedParts?: Record<string, { captured: boolean; url?: string }>;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export class ClaimListItemV2Dto {
|
|||||||
|
|
||||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True when the claim is in the expert factor validation queue (all factors uploaded).',
|
||||||
|
})
|
||||||
|
awaitingFactorValidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetClaimListV2ResponseDto {
|
export class GetClaimListV2ResponseDto {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
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';
|
||||||
|
|
||||||
export class CarPartDamageV2Dto {
|
export class CarPartDamageV2Dto {
|
||||||
@ApiProperty({ example: 'left' })
|
@ApiProperty({ example: 'left' })
|
||||||
@@ -68,3 +70,14 @@ export class SubmitExpertReplyV2Dto {
|
|||||||
@Type(() => PartPricingV2Dto)
|
@Type(() => PartPricingV2Dto)
|
||||||
parts: PartPricingV2Dto[];
|
parts: PartPricingV2Dto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ClaimSubmitResendV2Dto {
|
||||||
|
@ApiProperty({ required: true })
|
||||||
|
resendDescription: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: true, examples: ClaimRequiredDocumentType })
|
||||||
|
resendDocuments: ClaimRequiredDocumentType[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: 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 { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -31,3 +31,45 @@ export class FactorValidationDto {
|
|||||||
@Type(() => FactorDecisionDto)
|
@Type(() => FactorDecisionDto)
|
||||||
decisions: 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[];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|||||||
import { HttpService } from "@nestjs/axios";
|
import { HttpService } from "@nestjs/axios";
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -32,15 +33,24 @@ import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
|||||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||||
|
import {
|
||||||
|
assertClaimCaseForTenant,
|
||||||
|
claimCaseTouchesClient,
|
||||||
|
requireActorClientKey,
|
||||||
|
} from "src/helpers/tenant-scope";
|
||||||
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
|
||||||
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
|
||||||
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
|
||||||
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
|
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
|
||||||
|
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
|
||||||
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
|
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
|
||||||
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
||||||
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
|
||||||
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
|
import { ClaimSubmitResendV2Dto } from "./dto/expert-claim-v2.dto";
|
||||||
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
|
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertClaimService {
|
export class ExpertClaimService {
|
||||||
@@ -153,13 +163,14 @@ export class ExpertClaimService {
|
|||||||
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly sandHubService: SandHubService,
|
private readonly sandHubService: SandHubService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
private readonly clientDbService: ClientDbService,
|
private readonly clientDbService: ClientDbService,
|
||||||
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
private readonly claimFactorsImageDbService: ClaimFactorsImageDbService,
|
||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
private isVisibleToClientType(client: any, actor: any): boolean {
|
private isVisibleToClientType(client: any, actor: any): boolean {
|
||||||
if (actor.userType === UserType.GENUINE) {
|
if (actor.userType === UserType.GENUINE) {
|
||||||
@@ -313,8 +324,18 @@ export class ExpertClaimService {
|
|||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
if (!request) throw new BadRequestException("Claim request not found");
|
if (!request) throw new BadRequestException("Claim request not found");
|
||||||
|
|
||||||
if (request.lockFile) {
|
const isLocked = !!request.lockFile;
|
||||||
throw new BadRequestException("Claim request is locked");
|
const lockedByCurrent =
|
||||||
|
String(request?.actorLocked?.actorId || "") === String(actorDetail.sub);
|
||||||
|
const isLockExpired =
|
||||||
|
!!request.unlockTime && Date.now() >= new Date(request.unlockTime).getTime();
|
||||||
|
|
||||||
|
// Idempotent behavior: same expert can re-enter their locked file.
|
||||||
|
if (isLocked && lockedByCurrent && !isLockExpired) {
|
||||||
|
return { _id: requestId, lock: true, message: "Already locked by you" };
|
||||||
|
}
|
||||||
|
if (isLocked && !lockedByCurrent && !isLockExpired) {
|
||||||
|
throw new BadRequestException("Claim request is locked by another expert");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.claimStatus === ReqClaimStatus.UserPending) {
|
if (request.claimStatus === ReqClaimStatus.UserPending) {
|
||||||
@@ -473,7 +494,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
async calculatePriceDrop(request: any, carPartsAi: any, query?: any) {
|
||||||
const requestId = request?._id?.toString() || 'unknown';
|
const requestId = request?._id?.toString() || 'unknown';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Check SandHub data
|
// Step 1: Check SandHub data
|
||||||
if (!request?.blameFile?.sanHubId) {
|
if (!request?.blameFile?.sanHubId) {
|
||||||
@@ -531,7 +552,7 @@ export class ExpertClaimService {
|
|||||||
} else if (severityValues.length > 0) {
|
} else if (severityValues.length > 0) {
|
||||||
// Only fetch car prices if we have severity values to calculate
|
// Only fetch car prices if we have severity values to calculate
|
||||||
const carPrices = await this.fetchCarPrices();
|
const carPrices = await this.fetchCarPrices();
|
||||||
|
|
||||||
if (!carPrices || carPrices.length === 0) {
|
if (!carPrices || carPrices.length === 0) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
|
`[PriceDrop] Request ${requestId}: Failed to fetch car prices or car prices list is empty. Cannot find matching car.`,
|
||||||
@@ -541,9 +562,9 @@ export class ExpertClaimService {
|
|||||||
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
`[PriceDrop] Request ${requestId}: Fetched ${carPrices.length} car prices. Searching for: "${request.carDetail.carName}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const closestCar = this.findClosestCar(
|
const closestCar = this.findClosestCar(
|
||||||
request.carDetail.carName,
|
request.carDetail.carName,
|
||||||
carPrices,
|
carPrices,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!closestCar) {
|
if (!closestCar) {
|
||||||
@@ -557,11 +578,11 @@ export class ExpertClaimService {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
`[PriceDrop] Request ${requestId}: Found matching car "${closestCar.carName}" with marketPrice: ${closestCar.marketPrice}`,
|
||||||
);
|
);
|
||||||
autoCalculatedData = {
|
autoCalculatedData = {
|
||||||
carPrice: closestCar.marketPrice,
|
carPrice: closestCar.marketPrice,
|
||||||
carValue: severityValues,
|
carValue: severityValues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,10 +620,10 @@ export class ExpertClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Save to database
|
// Step 6: Save to database
|
||||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||||
{ _id: new Types.ObjectId(request._id) },
|
{ _id: new Types.ObjectId(request._id) },
|
||||||
{ $set: { priceDrop: finalPriceDropData } },
|
{ $set: { priceDrop: finalPriceDropData } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
|
`[PriceDrop] Request ${requestId}: Price drop calculation completed and saved.`,
|
||||||
@@ -670,7 +691,7 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const severityValue =
|
const severityValue =
|
||||||
this.priceDropPart[originalPriceDropKey]?.[
|
this.priceDropPart[originalPriceDropKey]?.[
|
||||||
damagedPartInfo.severity
|
damagedPartInfo.severity
|
||||||
];
|
];
|
||||||
|
|
||||||
if (severityValue !== undefined) {
|
if (severityValue !== undefined) {
|
||||||
@@ -967,13 +988,13 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
private isCurrentUserAllowed(request: any, currentUser: any): boolean {
|
||||||
// Check if locked by current user and lock is still active
|
// Check if locked by current user and lock is still active
|
||||||
const isLockedByCurrentUser =
|
const isLockedByCurrentUser =
|
||||||
String(request?.actorLocked?.actorId) === currentUser.sub &&
|
String(request?.actorLocked?.actorId) === currentUser.sub &&
|
||||||
request.lockFile;
|
request.lockFile;
|
||||||
|
|
||||||
if (!isLockedByCurrentUser) {
|
if (!isLockedByCurrentUser) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if lock has expired (unlockTime has passed)
|
// Also check if lock has expired (unlockTime has passed)
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
@@ -985,7 +1006,7 @@ export class ExpertClaimService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,7 +1014,7 @@ export class ExpertClaimService {
|
|||||||
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
|
if (!request.lockFile || request.claimStatus !== ReqClaimStatus.ReviewRequest) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired
|
// Check if lock has expired
|
||||||
if (request.unlockTime) {
|
if (request.unlockTime) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
@@ -1003,15 +1024,15 @@ export class ExpertClaimService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If currentUser is provided, allow access if they are the one who locked it
|
// If currentUser is provided, allow access if they are the one who locked it
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const isLockedByCurrentUser =
|
const isLockedByCurrentUser =
|
||||||
String(request?.actorLocked?.actorId) === currentUser.sub;
|
String(request?.actorLocked?.actorId) === currentUser.sub;
|
||||||
// Return false (not locked) if locked by current user, true if locked by someone else
|
// Return false (not locked) if locked by current user, true if locked by someone else
|
||||||
return !isLockedByCurrentUser;
|
return !isLockedByCurrentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no currentUser provided, treat as locked
|
// If no currentUser provided, treat as locked
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1034,18 +1055,18 @@ export class ExpertClaimService {
|
|||||||
) {
|
) {
|
||||||
throw new ForbiddenException("Access denied to this request");
|
throw new ForbiddenException("Access denied to this request");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if lock has expired (unlockTime has passed)
|
// Check if lock has expired (unlockTime has passed)
|
||||||
if (request?.unlockTime && !request?.objection) {
|
if (request?.unlockTime && !request?.objection) {
|
||||||
const unlockTime = new Date(request.unlockTime).getTime();
|
const unlockTime = new Date(request.unlockTime).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now >= unlockTime) {
|
if (now >= unlockTime) {
|
||||||
throw new ForbiddenException("Your time has expired");
|
throw new ForbiddenException("Your time has expired");
|
||||||
}
|
}
|
||||||
} else if (request?.unlockTime == null && !request?.objection) {
|
} else if (request?.unlockTime == null && !request?.objection) {
|
||||||
throw new ForbiddenException("Your time has expired");
|
throw new ForbiddenException("Your time has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.lockFile && !request?.objection) {
|
if (!request.lockFile && !request?.objection) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
"For submit reply you must lock the request",
|
"For submit reply you must lock the request",
|
||||||
@@ -1254,6 +1275,66 @@ export class ExpertClaimService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async streamServiceV2(requestId, query, res, header) {
|
||||||
|
const request = await this.claimCaseDbService.findById(requestId);
|
||||||
|
console.log(request)
|
||||||
|
// const blameCase = await this.blameCaseDbService
|
||||||
|
return request;
|
||||||
|
// let videoPath: string = "";
|
||||||
|
// switch (query) {
|
||||||
|
// case "accident":
|
||||||
|
// videoPath = await this.getAccidentVideoPath(
|
||||||
|
// String(
|
||||||
|
// request?.blameFile?.firstPartyDetails?.firstPartyFile
|
||||||
|
// ?.firstPartyVideoId,
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
// break;
|
||||||
|
// case "car-capture":
|
||||||
|
// videoPath = await this.getCarCapture(requestId);
|
||||||
|
// break;
|
||||||
|
// default:
|
||||||
|
// throw new NotFoundException("Invalid query type");
|
||||||
|
// }
|
||||||
|
// if (!videoPath) throw new NotFoundException("video_not_found");
|
||||||
|
|
||||||
|
// const absolutePath = join(process.cwd(), videoPath.replace(/^\/+/, ""));
|
||||||
|
|
||||||
|
// if (!existsSync(absolutePath)) {
|
||||||
|
// throw new NotFoundException(
|
||||||
|
// `Video file not found at path: ${absolutePath}`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const { size } = statSync(absolutePath);
|
||||||
|
// const range = header.range;
|
||||||
|
|
||||||
|
// if (range) {
|
||||||
|
// const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
|
||||||
|
// const start = parseInt(startStr, 10);
|
||||||
|
// const end = endStr ? parseInt(endStr, 10) : size - 1;
|
||||||
|
// const chunkSize = end - start + 1;
|
||||||
|
|
||||||
|
// const file = createReadStream(absolutePath, { start, end });
|
||||||
|
|
||||||
|
// res.writeHead(206, {
|
||||||
|
// "Content-Range": `bytes ${start}-${end}/${size}`,
|
||||||
|
// "Accept-Ranges": "bytes",
|
||||||
|
// "Content-Length": chunkSize,
|
||||||
|
// "Content-Type": "video/mp4",
|
||||||
|
// });
|
||||||
|
|
||||||
|
// file.pipe(res);
|
||||||
|
// } else {
|
||||||
|
// res.writeHead(200, {
|
||||||
|
// "Content-Length": size,
|
||||||
|
// "Content-Type": "video/mp4",
|
||||||
|
// });
|
||||||
|
|
||||||
|
// createReadStream(absolutePath).pipe(res);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
async streamService(requestId, query, res, header) {
|
async streamService(requestId, query, res, header) {
|
||||||
const request =
|
const request =
|
||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
@@ -1527,6 +1608,210 @@ export class ExpertClaimService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 (ClaimCase): validate user-uploaded repair factors on the active expert reply.
|
||||||
|
* Preconditions: all `factorNeeded` parts have `factorLink`; case is UNDER_REVIEW at EXPERT_COST_EVALUATION.
|
||||||
|
* — All approved → same as non-factor path: claimStatus APPROVED, step INSURER_REVIEW.
|
||||||
|
* — Any rejected (expert repriced) → case COMPLETED with expert amounts (damage-expert finalization).
|
||||||
|
*/
|
||||||
|
async validateClaimFactorsV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
body: FactorValidationV2Dto,
|
||||||
|
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||||
|
) {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
|
if (
|
||||||
|
claim.status !== ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL ||
|
||||||
|
claim.claimStatus !== ClaimStatus.UNDER_REVIEW ||
|
||||||
|
claim.workflow?.currentStep !== ClaimWorkflowStep.EXPERT_COST_EVALUATION
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Claim is not awaiting expert factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyField = claim.evaluation?.damageExpertReplyFinal
|
||||||
|
? "damageExpertReplyFinal"
|
||||||
|
: "damageExpertReply";
|
||||||
|
const finalReply =
|
||||||
|
replyField === "damageExpertReplyFinal"
|
||||||
|
? claim.evaluation?.damageExpertReplyFinal
|
||||||
|
: claim.evaluation?.damageExpertReply;
|
||||||
|
|
||||||
|
if (!finalReply?.parts?.length) {
|
||||||
|
throw new BadRequestException("No expert reply found in this claim.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFactors = finalReply.parts.filter((p) => p.factorNeeded);
|
||||||
|
if (requiredFactors.length === 0) {
|
||||||
|
throw new BadRequestException("No parts require factor validation.");
|
||||||
|
}
|
||||||
|
if (!requiredFactors.every((p) => !!p.factorLink)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Not all required factors have been uploaded yet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const $set: Record<string, unknown> = {};
|
||||||
|
for (const decision of body.decisions) {
|
||||||
|
const partIndex = finalReply.parts.findIndex(
|
||||||
|
(p) => String(p.partId) === String(decision.partId),
|
||||||
|
);
|
||||||
|
if (partIndex === -1) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Part ${decision.partId} not found in claim ${claimRequestId}, skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const part = finalReply.parts[partIndex];
|
||||||
|
if (!part.factorNeeded) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Part ${decision.partId} is not factor-needed, skipping.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = `evaluation.${replyField}.parts.${partIndex}`;
|
||||||
|
$set[`${base}.factorStatus`] = decision.status;
|
||||||
|
$set[`${base}.rejectionReason`] = decision.rejectionReason ?? null;
|
||||||
|
if (decision.price !== undefined) {
|
||||||
|
$set[`${base}.price`] = decision.price;
|
||||||
|
}
|
||||||
|
if (decision.salary !== undefined) {
|
||||||
|
$set[`${base}.salary`] = decision.salary;
|
||||||
|
}
|
||||||
|
if (decision.totalPayment !== undefined) {
|
||||||
|
$set[`${base}.totalPayment`] = decision.totalPayment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.status === FactorStatus.REJECTED) {
|
||||||
|
const hasOverride =
|
||||||
|
decision.totalPayment != null ||
|
||||||
|
(decision.price != null && decision.salary != null);
|
||||||
|
if (!hasOverride) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Part ${decision.partId}: rejected factors require expert totalPayment (or both price and salary).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys($set).length === 0) {
|
||||||
|
throw new BadRequestException("No valid factor decisions to apply.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedClaim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
const updatedReply =
|
||||||
|
replyField === "damageExpertReplyFinal"
|
||||||
|
? updatedClaim!.evaluation!.damageExpertReplyFinal!
|
||||||
|
: updatedClaim!.evaluation!.damageExpertReply!;
|
||||||
|
|
||||||
|
const reqParts = updatedReply.parts.filter((p) => p.factorNeeded);
|
||||||
|
const anyRejected = reqParts.some(
|
||||||
|
(p) => p.factorStatus === FactorStatus.REJECTED,
|
||||||
|
);
|
||||||
|
const anyStillPending = reqParts.some(
|
||||||
|
(p) => !p.factorStatus || p.factorStatus === FactorStatus.PENDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseMessage = "Factor validation updated.";
|
||||||
|
if (anyStillPending) {
|
||||||
|
return {
|
||||||
|
message: `${baseMessage} Some factors are still pending review.`,
|
||||||
|
claimRequestId,
|
||||||
|
claimStatus: updatedClaim!.claimStatus,
|
||||||
|
caseStatus: updatedClaim!.status,
|
||||||
|
pendingFactorValidation: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICE_CAP = 30_000_000;
|
||||||
|
let totalPrice = 0;
|
||||||
|
for (const part of updatedReply.parts || []) {
|
||||||
|
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
totalPrice += parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalPrice > PRICE_CAP) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: `Total price (${totalPrice.toLocaleString()}) exceeds the cap of ${PRICE_CAP.toLocaleString()}`,
|
||||||
|
error: "PRICE_CAP_ERROR",
|
||||||
|
totalPrice,
|
||||||
|
priceCap: PRICE_CAP,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyActor = {
|
||||||
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: "damage_expert",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (anyRejected) {
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
status: ClaimCaseStatus.COMPLETED,
|
||||||
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "FACTORS_VALIDATED_REJECTED_EXPERT_FINALIZED",
|
||||||
|
actor: historyActor,
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { replyField },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"Factors reviewed. Rejected parts were repriced by the expert; the claim is completed.",
|
||||||
|
claimRequestId,
|
||||||
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
|
caseStatus: ClaimCaseStatus.COMPLETED,
|
||||||
|
outcome: "REJECTED_REPRICED_COMPLETED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
|
"workflow.currentStep": ClaimWorkflowStep.INSURER_REVIEW,
|
||||||
|
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: "FACTORS_VALIDATED_ALL_APPROVED",
|
||||||
|
actor: historyActor,
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: { replyField },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"All factors approved. The claim proceeds to insurer review (same as non-factor expert reply).",
|
||||||
|
claimRequestId,
|
||||||
|
claimStatus: ClaimStatus.APPROVED,
|
||||||
|
caseStatus: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
|
outcome: "ALL_APPROVED_INSURER_REVIEW",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async inPersonVisit(requestId: string, actorDetail: any) {
|
async inPersonVisit(requestId: string, actorDetail: any) {
|
||||||
const request =
|
const request =
|
||||||
await this.claimRequestManagementDbService.findOne(requestId);
|
await this.claimRequestManagementDbService.findOne(requestId);
|
||||||
@@ -1574,12 +1859,15 @@ export class ExpertClaimService {
|
|||||||
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
|
* - Sets currentStep = EXPERT_DAMAGE_ASSESSMENT
|
||||||
*/
|
*/
|
||||||
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
async lockClaimRequestV2(claimRequestId: string, actor: any) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not available for locking. Current status: ${claim.status}`,
|
`Claim is not available for locking. Current status: ${claim.status}`,
|
||||||
@@ -1634,6 +1922,80 @@ export class ExpertClaimService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async submitResendDocsV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
reply: ClaimSubmitResendV2Dto,
|
||||||
|
actor: any,
|
||||||
|
) {
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException('Claim request not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claim.workflow?.locked) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You must lock the claim before submitting a resend request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||||||
|
throw new ForbiddenException('This claim is locked by another expert');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResend = claim.evaluation?.damageExpertResend;
|
||||||
|
const resendHasContent =
|
||||||
|
existingResend &&
|
||||||
|
(String(existingResend.resendDescription || '').trim() !== '' ||
|
||||||
|
(existingResend.resendDocuments?.length ?? 0) > 0 ||
|
||||||
|
(existingResend.resendCarParts?.length ?? 0) > 0);
|
||||||
|
if (resendHasContent) {
|
||||||
|
throw new ConflictException('A resend request already exists for this claim');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||||
|
$set: {
|
||||||
|
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||||
|
'workflow.locked': false,
|
||||||
|
'evaluation.damageExpertResend': {
|
||||||
|
resendDescription: reply.resendDescription,
|
||||||
|
resendDocuments: reply.resendDocuments ?? [],
|
||||||
|
resendCarParts: reply.resendCarParts ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
history: {
|
||||||
|
type: 'EXPERT_RESEND_REQUESTED',
|
||||||
|
actor: {
|
||||||
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: 'damage_expert',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
documentCount: reply.resendDocuments?.length ?? 0,
|
||||||
|
carPartCount: reply.resendCarParts?.length ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId,
|
||||||
|
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||||
|
message:
|
||||||
|
'Resend request recorded. The damaged party can review requirements and submit an objection or updated materials.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2: Submit damage expert reply for a claim.
|
* V2: Submit damage expert reply for a claim.
|
||||||
*
|
*
|
||||||
@@ -1654,12 +2016,15 @@ export class ExpertClaimService {
|
|||||||
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
|
reply: import('./dto/expert-claim-v2.dto').SubmitExpertReplyV2Dto,
|
||||||
actor: any,
|
actor: any,
|
||||||
) {
|
) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in a reviewable state. Current status: ${claim.status}`,
|
`Claim is not in a reviewable state. Current status: ${claim.status}`,
|
||||||
@@ -1694,6 +2059,23 @@ export class ExpertClaimService {
|
|||||||
|
|
||||||
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
const needsFactorUpload = reply.parts?.some((p) => p.factorNeeded === true) ?? false;
|
||||||
|
|
||||||
|
const objectionSubmitted = !!claim.evaluation?.objection?.submittedAt;
|
||||||
|
const hasFinalReply = !!claim.evaluation?.damageExpertReplyFinal;
|
||||||
|
|
||||||
|
if (objectionSubmitted && hasFinalReply) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A final expert reply after objection already exists for this claim.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFinalReplyAfterObjection = objectionSubmitted && !hasFinalReply;
|
||||||
|
const replyField = isFinalReplyAfterObjection
|
||||||
|
? 'damageExpertReplyFinal'
|
||||||
|
: 'damageExpertReply';
|
||||||
|
const completedStep = isFinalReplyAfterObjection
|
||||||
|
? ClaimWorkflowStep.EXPERT_FINAL_REPLY
|
||||||
|
: ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT;
|
||||||
|
|
||||||
const replyPayload = {
|
const replyPayload = {
|
||||||
description: reply.description,
|
description: reply.description,
|
||||||
parts: reply.parts,
|
parts: reply.parts,
|
||||||
@@ -1713,7 +2095,7 @@ export class ExpertClaimService {
|
|||||||
? ClaimStatus.NEEDS_REVISION
|
? ClaimStatus.NEEDS_REVISION
|
||||||
: ClaimStatus.APPROVED;
|
: ClaimStatus.APPROVED;
|
||||||
|
|
||||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
const updatePayload: Record<string, unknown> = {
|
||||||
status: nextCaseStatus,
|
status: nextCaseStatus,
|
||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
'workflow.locked': false,
|
'workflow.locked': false,
|
||||||
@@ -1721,18 +2103,30 @@ export class ExpertClaimService {
|
|||||||
'workflow.nextStep': needsFactorUpload
|
'workflow.nextStep': needsFactorUpload
|
||||||
? ClaimWorkflowStep.INSURER_REVIEW
|
? ClaimWorkflowStep.INSURER_REVIEW
|
||||||
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
: ClaimWorkflowStep.CLAIM_COMPLETED,
|
||||||
'evaluation.damageExpertReply': replyPayload,
|
[`evaluation.${replyField}`]: replyPayload,
|
||||||
$push: {
|
$push: {
|
||||||
'workflow.completedSteps': ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
|
'workflow.completedSteps': completedStep,
|
||||||
history: {
|
history: {
|
||||||
event: 'EXPERT_REPLY_SUBMITTED',
|
type: isFinalReplyAfterObjection
|
||||||
performedBy: actor.sub,
|
? 'EXPERT_FINAL_REPLY_SUBMITTED'
|
||||||
performedByName: actor.fullName,
|
: 'EXPERT_REPLY_SUBMITTED',
|
||||||
performedAt: new Date(),
|
actor: {
|
||||||
note: `Expert reply submitted. factorNeeded=${needsFactorUpload}`,
|
actorId: new Types.ObjectId(actor.sub),
|
||||||
|
actorName: actor.fullName,
|
||||||
|
actorType: 'damage_expert',
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
factorNeeded: needsFactorUpload,
|
||||||
|
partsCount: reply.parts?.length ?? 0,
|
||||||
|
isFinalReplyAfterObjection,
|
||||||
|
replyField,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updatePayload);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
claimRequestId,
|
claimRequestId,
|
||||||
@@ -1740,6 +2134,7 @@ export class ExpertClaimService {
|
|||||||
claimStatus: nextClaimStatus,
|
claimStatus: nextClaimStatus,
|
||||||
currentStep: nextStep,
|
currentStep: nextStep,
|
||||||
factorNeeded: needsFactorUpload,
|
factorNeeded: needsFactorUpload,
|
||||||
|
isFinalReplyAfterObjection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1761,12 +2156,15 @@ export class ExpertClaimService {
|
|||||||
* - Unlocks the workflow so user can act
|
* - Unlocks the workflow so user can act
|
||||||
*/
|
*/
|
||||||
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
|
async requestInPersonVisitV2(claimRequestId: string, actor: any, note?: string) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
@@ -1809,12 +2207,15 @@ export class ExpertClaimService {
|
|||||||
/**
|
/**
|
||||||
* V2: Get claim list for damage expert
|
* V2: Get claim list for damage expert
|
||||||
*
|
*
|
||||||
* Returns claims that are:
|
* Returns claims that are (then filtered to the actor's insurer via `claimCaseTouchesClient`):
|
||||||
* 1. WAITING_FOR_DAMAGE_EXPERT status AND not locked (available for anyone to pick)
|
* 1. WAITING_FOR_DAMAGE_EXPERT and not locked (open queue)
|
||||||
* 2. WAITING_FOR_DAMAGE_EXPERT status AND not locked (claimStatus PENDING)
|
* 2. Locked by this expert (in-progress)
|
||||||
* 3. Any status locked by THIS expert (their own in-progress work)
|
* 3. WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION (factor validation queue)
|
||||||
*/
|
*/
|
||||||
async getClaimListV2(actorId: string): Promise<GetClaimListV2ResponseDto> {
|
async getClaimListV2(actor: any): Promise<GetClaimListV2ResponseDto> {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const actorId = actor.sub;
|
||||||
|
const clientKey = actor.clientKey as string;
|
||||||
const claims = await this.claimCaseDbService.find({
|
const claims = await this.claimCaseDbService.find({
|
||||||
$or: [
|
$or: [
|
||||||
// Available claims: waiting for expert, not locked
|
// Available claims: waiting for expert, not locked
|
||||||
@@ -1827,30 +2228,45 @@ export class ExpertClaimService {
|
|||||||
'workflow.locked': true,
|
'workflow.locked': true,
|
||||||
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
|
'workflow.lockedBy.actorId': new Types.ObjectId(actorId),
|
||||||
},
|
},
|
||||||
|
// User uploaded all factors; expert must approve/reject (unlocked queue)
|
||||||
|
{
|
||||||
|
status: ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL,
|
||||||
|
claimStatus: ClaimStatus.UNDER_REVIEW,
|
||||||
|
'workflow.currentStep': ClaimWorkflowStep.EXPERT_COST_EVALUATION,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = (claims as any[]).map((c) => ({
|
const list = (claims as any[])
|
||||||
claimRequestId: c._id.toString(),
|
.filter((c) => claimCaseTouchesClient(c, clientKey))
|
||||||
publicId: c.publicId,
|
.map((c) => {
|
||||||
status: c.status,
|
const awaitingFactorValidation =
|
||||||
currentStep: c.workflow?.currentStep || '',
|
c.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
locked: c.workflow?.locked || false,
|
c.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||||
lockedBy: c.workflow?.lockedBy
|
c.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
? {
|
return {
|
||||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
claimRequestId: c._id.toString(),
|
||||||
actorName: c.workflow.lockedBy.actorName,
|
publicId: c.publicId,
|
||||||
}
|
status: c.status,
|
||||||
: undefined,
|
currentStep: c.workflow?.currentStep || '',
|
||||||
vehicle: c.vehicle
|
locked: c.workflow?.locked || false,
|
||||||
? {
|
lockedBy: c.workflow?.lockedBy
|
||||||
carName: c.vehicle.carName,
|
? {
|
||||||
carModel: c.vehicle.carModel,
|
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||||
carType: c.vehicle.carType,
|
actorName: c.workflow.lockedBy.actorName,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
createdAt: c.createdAt,
|
vehicle: c.vehicle
|
||||||
})) as ClaimListItemV2Dto[];
|
? {
|
||||||
|
carName: c.vehicle.carName,
|
||||||
|
carModel: c.vehicle.carModel,
|
||||||
|
carType: c.vehicle.carType,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
awaitingFactorValidation,
|
||||||
|
};
|
||||||
|
}) as ClaimListItemV2Dto[];
|
||||||
|
|
||||||
return { list, total: list.length };
|
return { list, total: list.length };
|
||||||
}
|
}
|
||||||
@@ -1859,28 +2275,39 @@ export class ExpertClaimService {
|
|||||||
* V2: Get claim detail for damage expert
|
* V2: Get claim detail for damage expert
|
||||||
*
|
*
|
||||||
* Validations:
|
* Validations:
|
||||||
* - Claim must exist
|
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||||
* - Claim status must be WAITING_FOR_DAMAGE_EXPERT
|
* - Allowed when picking up/reviewing: WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert)
|
||||||
* - If claim is locked, only the locking expert can access it
|
* - Or when validating factors: WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW at EXPERT_COST_EVALUATION
|
||||||
*/
|
*/
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
claimRequestId: string,
|
claimRequestId: string,
|
||||||
actorId: string,
|
actor: any,
|
||||||
): Promise<ClaimDetailV2ResponseDto> {
|
): Promise<ClaimDetailV2ResponseDto> {
|
||||||
|
const actorId = actor.sub;
|
||||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
|
||||||
if (!claim) {
|
if (!claim) {
|
||||||
throw new NotFoundException('Claim request not found');
|
throw new NotFoundException('Claim request not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claim.status !== ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT) {
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
|
||||||
|
const isPickupReview =
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT;
|
||||||
|
const isFactorValidationPending =
|
||||||
|
claim.status === ClaimCaseStatus.WAITING_FOR_INSURER_APPROVAL &&
|
||||||
|
claim.claimStatus === ClaimStatus.UNDER_REVIEW &&
|
||||||
|
claim.workflow?.currentStep === ClaimWorkflowStep.EXPERT_COST_EVALUATION;
|
||||||
|
|
||||||
|
if (!isPickupReview && !isFactorValidationPending) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`This claim is not available for expert review. Current status: ${claim.status}`,
|
`This claim is not available for expert review. Current status: ${claim.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If locked by someone else, deny access
|
// If locked by someone else, deny access (initial pickup / assessment only)
|
||||||
if (
|
if (
|
||||||
|
isPickupReview &&
|
||||||
claim.workflow?.locked &&
|
claim.workflow?.locked &&
|
||||||
claim.workflow.lockedBy?.actorId?.toString() !== actorId
|
claim.workflow.lockedBy?.actorId?.toString() !== actorId
|
||||||
) {
|
) {
|
||||||
@@ -1943,16 +2370,16 @@ export class ExpertClaimService {
|
|||||||
locked: claim.workflow?.locked || false,
|
locked: claim.workflow?.locked || false,
|
||||||
lockedBy: claim.workflow?.lockedBy
|
lockedBy: claim.workflow?.lockedBy
|
||||||
? {
|
? {
|
||||||
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
actorId: claim.workflow.lockedBy.actorId?.toString(),
|
||||||
actorName: claim.workflow.lockedBy.actorName,
|
actorName: claim.workflow.lockedBy.actorName,
|
||||||
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
lockedAt: (claim.workflow as any).lockedAt?.toISOString(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: claim.owner
|
owner: claim.owner
|
||||||
? {
|
? {
|
||||||
userId: claim.owner.userId?.toString(),
|
userId: claim.owner.userId?.toString(),
|
||||||
fullName: claim.owner.fullName,
|
fullName: claim.owner.fullName,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
vehicle: claim.vehicle,
|
vehicle: claim.vehicle,
|
||||||
blameRequestId: claim.blameRequestId?.toString(),
|
blameRequestId: claim.blameRequestId?.toString(),
|
||||||
@@ -1965,8 +2392,90 @@ export class ExpertClaimService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
carAngles,
|
carAngles,
|
||||||
damagedParts,
|
damagedParts,
|
||||||
|
awaitingFactorValidation: isFactorValidationPending,
|
||||||
|
evaluation: isFactorValidationPending
|
||||||
|
? {
|
||||||
|
damageExpertReply: (claim as any).evaluation?.damageExpertReply,
|
||||||
|
damageExpertReplyFinal: (claim as any).evaluation?.damageExpertReplyFinal,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
createdAt: (claim as any).createdAt,
|
createdAt: (claim as any).createdAt,
|
||||||
updatedAt: (claim as any).updatedAt,
|
updatedAt: (claim as any).updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2: Damage expert can modify selected damaged parts before final submit.
|
||||||
|
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
|
||||||
|
*/
|
||||||
|
async updateClaimDamagedPartsV2(
|
||||||
|
claimRequestId: string,
|
||||||
|
body: UpdateClaimDamagedPartsV2Dto,
|
||||||
|
actor: any,
|
||||||
|
) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||||
|
if (!claim) {
|
||||||
|
throw new NotFoundException("Claim request not found");
|
||||||
|
}
|
||||||
|
assertClaimCaseForTenant(claim, actor);
|
||||||
|
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!claim.workflow?.locked) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"You must lock the claim before modifying damaged parts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
|
||||||
|
throw new ForbiddenException("This claim is locked by another expert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = Array.isArray((claim as any).damage?.selectedParts)
|
||||||
|
? [...(claim as any).damage.selectedParts]
|
||||||
|
: [];
|
||||||
|
const selectedParts = body.selectedParts as string[];
|
||||||
|
|
||||||
|
if (!(claim as any).damage) (claim as any).damage = {};
|
||||||
|
(claim as any).damage.selectedParts = selectedParts;
|
||||||
|
|
||||||
|
// Keep damaged-parts captures consistent with updated selection.
|
||||||
|
const damagedPartsMedia = (claim as any).media?.damagedParts;
|
||||||
|
if (damagedPartsMedia) {
|
||||||
|
const selectedSet = new Set(selectedParts);
|
||||||
|
if (damagedPartsMedia instanceof Map) {
|
||||||
|
for (const key of Array.from(damagedPartsMedia.keys())) {
|
||||||
|
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const key of Object.keys(damagedPartsMedia)) {
|
||||||
|
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray((claim as any).history)) (claim as any).history = [];
|
||||||
|
(claim as any).history.push({
|
||||||
|
event: "EXPERT_DAMAGED_PARTS_UPDATED",
|
||||||
|
performedBy: actor.sub,
|
||||||
|
performedByName: actor.fullName,
|
||||||
|
performedAt: new Date(),
|
||||||
|
note: "Damage expert edited selected damaged parts",
|
||||||
|
metadata: {
|
||||||
|
previousSelectedParts: previous,
|
||||||
|
selectedParts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await (claim as any).save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimRequestId: claim._id.toString(),
|
||||||
|
selectedParts,
|
||||||
|
previousSelectedParts: previous,
|
||||||
|
message: "Damaged parts updated successfully.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { IsOptional, IsString } from "class-validator";
|
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 { CurrentUser } from "src/decorators/user.decorator";
|
||||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
import { ExpertClaimService } from "./expert-claim.service";
|
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 {
|
class InPersonVisitV2Dto {
|
||||||
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
|
||||||
@@ -29,33 +32,30 @@ class InPersonVisitV2Dto {
|
|||||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||||
export class ExpertClaimV2Controller {
|
export class ExpertClaimV2Controller {
|
||||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
constructor(private readonly expertClaimService: ExpertClaimService) { }
|
||||||
|
|
||||||
@Get("requests")
|
@Get("requests")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List available claim requests for damage expert",
|
summary: "List available claim requests for damage expert",
|
||||||
description:
|
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) {
|
async getClaimListV2(@CurrentUser() actor) {
|
||||||
return await this.expertClaimService.getClaimListV2(actor.sub);
|
return await this.expertClaimService.getClaimListV2(actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("request/:claimRequestId")
|
@Get("request/:claimRequestId")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get claim request detail for damage expert",
|
summary: "Get claim request detail for damage expert",
|
||||||
description:
|
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 status, and damage selections. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation (WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION); in the latter case `evaluation` includes the active expert reply for factors.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "claimRequestId" })
|
@ApiParam({ name: "claimRequestId" })
|
||||||
async getClaimDetailV2(
|
async getClaimDetailV2(
|
||||||
@Param("claimRequestId") claimRequestId: string,
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
@CurrentUser() actor,
|
@CurrentUser() actor,
|
||||||
) {
|
) {
|
||||||
return await this.expertClaimService.getClaimDetailV2(
|
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
|
||||||
claimRequestId,
|
|
||||||
actor.sub,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put("lock/:claimRequestId")
|
@Put("lock/:claimRequestId")
|
||||||
@@ -88,6 +88,22 @@ export class ExpertClaimV2Controller {
|
|||||||
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
|
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 (status EXPERT_REVIEWING). Submitting unlocks the claim. If any part has factorNeeded=true the claim moves to EXPERT_COST_EVALUATION; otherwise moves to INSURER_REVIEW. Total payment cap is 30,000,000.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId" })
|
||||||
|
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
||||||
|
async submitExpertResendV2(
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() body: ClaimSubmitResendV2Dto,
|
||||||
|
@CurrentUser() actor,
|
||||||
|
) {
|
||||||
|
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(":claimRequestId/visit")
|
@Patch(":claimRequestId/visit")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Request in-person visit for claim",
|
summary: "Request in-person visit for claim",
|
||||||
@@ -107,4 +123,61 @@ export class ExpertClaimV2Controller {
|
|||||||
body?.note,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||||
@Get("/:expertId")
|
@Get("/:expertId")
|
||||||
async requestDetail(
|
async requestDetail(
|
||||||
|
@CurrentUser() insurer,
|
||||||
@Param("expertId") id: string,
|
@Param("expertId") id: string,
|
||||||
@Query("role") role: "claim" | "blame",
|
@Query("role") role: "claim" | "blame",
|
||||||
) {
|
) {
|
||||||
@@ -89,7 +90,11 @@ export class ExpertInsurerController {
|
|||||||
throw new BadRequestException("Invalid role");
|
throw new BadRequestException("Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
|
return await this.expertInsurerService.getAllFilesByExpertAndRole(
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
insurer.clientKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@@ -153,6 +158,7 @@ export class ExpertInsurerController {
|
|||||||
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
|
||||||
@Put("/:requestId/rating")
|
@Put("/:requestId/rating")
|
||||||
async rateExperts(
|
async rateExperts(
|
||||||
|
@CurrentUser() insurer,
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@Body() rating: FileRating,
|
@Body() rating: FileRating,
|
||||||
@Query("role") role: "claim" | "blame",
|
@Query("role") role: "claim" | "blame",
|
||||||
@@ -165,13 +171,13 @@ export class ExpertInsurerController {
|
|||||||
requestId,
|
requestId,
|
||||||
rating,
|
rating,
|
||||||
role,
|
role,
|
||||||
|
insurer.clientKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("branches/:insuranceId")
|
@Get("branches/:insuranceId")
|
||||||
@ApiParam({ name: "insuranceId" })
|
@ApiParam({ name: "insuranceId" })
|
||||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||||
console.log("insuranceId", insuranceId);
|
|
||||||
return await this.expertInsurerService.retrieveInsuranceBranches(
|
return await this.expertInsurerService.retrieveInsuranceBranches(
|
||||||
insuranceId,
|
insuranceId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,163 +1,159 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InjectModel } from "@nestjs/mongoose";
|
import { Types } from "mongoose";
|
||||||
import { Model, Types } from "mongoose";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
|
|
||||||
import { VideoCaptureDbService } from "src/claim-request-management/entites/db-service/video-capture.db.service";
|
|
||||||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
|
||||||
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
|
||||||
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||||
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
import { BranchDbService } from "src/client/entities/db-service/branch.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
|
||||||
import { buildFileLink } from "src/helpers/urlCreator";
|
|
||||||
import { BlameDocumentDbService } from "src/request-management/entities/db-service/blame-document.db.service";
|
|
||||||
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
|
|
||||||
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
|
|
||||||
import { UserSignDbService } from "src/request-management/entities/db-service/sign.db.service";
|
|
||||||
import {
|
import {
|
||||||
FileRating,
|
blameCaseTouchesClient,
|
||||||
RequestManagementModel,
|
claimCaseTouchesClient,
|
||||||
} from "src/request-management/entities/schema/request-management.schema";
|
} from "src/helpers/tenant-scope";
|
||||||
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
|
import { FileRating } from "src/request-management/entities/schema/request-management.schema";
|
||||||
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.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 { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpertInsurerService {
|
export class ExpertInsurerService {
|
||||||
private readonly logger = new Logger(ExpertInsurerService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly client: ClientDbService,
|
|
||||||
private readonly expertDbService: ExpertDbService,
|
private readonly expertDbService: ExpertDbService,
|
||||||
private readonly damageExpertDbService: DamageExpertDbService,
|
private readonly damageExpertDbService: DamageExpertDbService,
|
||||||
@InjectModel(ClaimRequestManagementModel.name)
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
@InjectModel(RequestManagementModel.name)
|
|
||||||
private readonly requestManagementModel: Model<RequestManagementModel>,
|
|
||||||
private readonly blameVoiceDbService: BlameVoiceDbService,
|
|
||||||
private readonly blameVideoDbService: BlameVideoDbService,
|
|
||||||
private readonly blameDocumentDbService: BlameDocumentDbService,
|
|
||||||
private readonly userSignDbService: UserSignDbService,
|
|
||||||
private readonly claimVideoCaptureDbService: VideoCaptureDbService,
|
|
||||||
private readonly branchDbService: BranchDbService,
|
private readonly branchDbService: BranchDbService,
|
||||||
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
|
|
||||||
private readonly damageImageDbService: DamageImageDbService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async retrieveAllExpertsOfClient(
|
private getClientId(actorOrId: any): Types.ObjectId {
|
||||||
actor,
|
const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
|
||||||
currentPage: number,
|
if (!raw || !Types.ObjectId.isValid(raw)) {
|
||||||
countPerPage: number,
|
throw new BadRequestException("Client key is required");
|
||||||
) {
|
}
|
||||||
const { clientKey } = actor;
|
return new Types.ObjectId(raw);
|
||||||
if (!clientKey) return;
|
}
|
||||||
|
|
||||||
const clientObjectId = new Types.ObjectId(clientKey);
|
private parseObjectId(value: string, label: string): Types.ObjectId {
|
||||||
|
if (!Types.ObjectId.isValid(value)) {
|
||||||
|
throw new BadRequestException(`Invalid ${label}`);
|
||||||
|
}
|
||||||
|
return new Types.ObjectId(value);
|
||||||
|
}
|
||||||
|
|
||||||
const [experts, damageExperts] = await Promise.all([
|
private normalizeClaimCase(claim: any): any {
|
||||||
this.expertDbService.findAll({ clientKey: clientKey }),
|
return {
|
||||||
|
...claim,
|
||||||
|
requestNumber: claim.requestNo,
|
||||||
|
userClientKey: claim.owner?.userClientKey,
|
||||||
|
fullName: claim.owner?.fullName,
|
||||||
|
carDetail: claim.vehicle,
|
||||||
|
carPlate: claim.vehicle?.plate,
|
||||||
|
claimStatus: claim.status,
|
||||||
|
rating: claim.evaluation?.rating,
|
||||||
|
userRating: claim.evaluation?.userRating,
|
||||||
|
damageExpertReply: claim.evaluation?.damageExpertReply,
|
||||||
|
damageExpertReplyFinal: claim.evaluation?.damageExpertReplyFinal,
|
||||||
|
damageExpertResend: claim.evaluation?.damageExpertResend,
|
||||||
|
objection: claim.evaluation?.objection,
|
||||||
|
userResendDocuments: claim.evaluation?.userResendDocuments,
|
||||||
|
priceDrop: claim.evaluation?.priceDrop,
|
||||||
|
visitLocation: claim.evaluation?.visitLocation,
|
||||||
|
currentStep: claim.workflow?.currentStep,
|
||||||
|
nextStep: claim.workflow?.nextStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBlameCase(blame: any): any {
|
||||||
|
const firstParty = blame?.parties?.find((p) => p?.role === "FIRST");
|
||||||
|
const secondParty = blame?.parties?.find((p) => p?.role === "SECOND");
|
||||||
|
return {
|
||||||
|
...blame,
|
||||||
|
requestNumber: blame.requestNo,
|
||||||
|
rating: blame?.expert?.rating,
|
||||||
|
actorLocked: { actorId: blame?.expert?.assignedExpertId },
|
||||||
|
firstPartyDetails: {
|
||||||
|
firstPartyClient: { clientId: firstParty?.person?.clientId },
|
||||||
|
},
|
||||||
|
secondPartyDetails: {
|
||||||
|
secondPartyClient: { clientId: secondParty?.person?.clientId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCombinedFileScore(file: any): number | null {
|
||||||
|
const insurerRating = file?.rating;
|
||||||
|
const userRating = file?.userRating;
|
||||||
|
const insurerValues = insurerRating
|
||||||
|
? Object.values(insurerRating).filter(
|
||||||
|
(v): v is number => typeof v === "number" && !isNaN(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const userValues = userRating
|
||||||
|
? [userRating.progressSpeed, userRating.registrationEase, userRating.overallEvaluation].filter(
|
||||||
|
(v) => typeof v === "number" && !isNaN(v),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const insurerAvg = insurerValues.length
|
||||||
|
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
||||||
|
: null;
|
||||||
|
const userAvg = userValues.length
|
||||||
|
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
||||||
|
: null;
|
||||||
|
const scores = [insurerAvg, userAvg].filter((v): v is number => typeof v === "number");
|
||||||
|
if (!scores.length) return null;
|
||||||
|
return parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClientBlameFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
||||||
|
const all = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||||
|
const idStr = String(clientObjectId);
|
||||||
|
return all
|
||||||
|
.filter((f) =>
|
||||||
|
(f?.parties || []).some((p) => String(p?.person?.clientId || "") === idStr),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
f?.status !== CaseStatus.OPEN &&
|
||||||
|
f?.status !== CaseStatus.WAITING_FOR_SECOND_PARTY,
|
||||||
|
)
|
||||||
|
.map((f) => this.normalizeBlameCase(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClientClaimFiles(clientObjectId: Types.ObjectId): Promise<any[]> {
|
||||||
|
const all = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
|
const idStr = String(clientObjectId);
|
||||||
|
return all
|
||||||
|
.filter((f) => String(f?.owner?.userClientKey || "") === idStr)
|
||||||
|
.map((f) => this.normalizeClaimCase(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
async retrieveAllExpertsOfClient(actor, currentPage: number, countPerPage: number) {
|
||||||
|
const clientObjectId = this.getClientId(actor);
|
||||||
|
const [experts, damageExperts, blameFiles, claimFiles] = await Promise.all([
|
||||||
|
this.expertDbService.findAll({ clientKey: String(clientObjectId) }),
|
||||||
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
|
||||||
|
this.getClientBlameFiles(clientObjectId),
|
||||||
|
this.getClientClaimFiles(clientObjectId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allExpertsRaw = [...experts, ...damageExperts];
|
const allExpertsRaw = [...experts, ...damageExperts];
|
||||||
const expertIds = allExpertsRaw.map((expert) => expert._id.toString());
|
|
||||||
|
|
||||||
if (expertIds.length === 0)
|
|
||||||
return { total: 0, page: currentPage, countPerPage, experts: [] };
|
|
||||||
|
|
||||||
const expertObjectIds = expertIds.map((id) => new Types.ObjectId(id));
|
|
||||||
|
|
||||||
const blameFileQuery = {
|
|
||||||
$and: [
|
|
||||||
{
|
|
||||||
$or: [
|
|
||||||
{ "firstPartyDetails.firstPartyClient.clientId": clientObjectId },
|
|
||||||
{ "secondPartyDetails.secondPartyClient.clientId": clientObjectId },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ "actorLocked.actorId": { $in: expertObjectIds } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const claimFileQuery = {
|
|
||||||
$and: [
|
|
||||||
{ userClientKey: clientObjectId },
|
|
||||||
{ "damageExpertReply.actorDetail.actorId": { $in: expertIds } },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const [blameFiles, claimFiles] = await Promise.all([
|
|
||||||
this.requestManagementModel.find(blameFileQuery).lean(),
|
|
||||||
this.claimRequestManagementModel.find(claimFileQuery).lean(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const expertTotalRatingsMap: Record<string, number[]> = {};
|
const expertTotalRatingsMap: Record<string, number[]> = {};
|
||||||
const expertRatingsByCategoryMap: Record<
|
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
|
||||||
string,
|
|
||||||
Record<string, number[]>
|
|
||||||
> = {};
|
|
||||||
|
|
||||||
/**
|
const processRatings = (expertId: string | undefined, file: any) => {
|
||||||
* Converts insurer FileRating and optional userRating into a single
|
|
||||||
* combined score for a file.
|
|
||||||
*/
|
|
||||||
const getCombinedFileScore = (file: any): number | null => {
|
|
||||||
const insurerRating = file?.rating;
|
|
||||||
const userRating = file?.userRating;
|
|
||||||
|
|
||||||
const insurerValues = insurerRating
|
|
||||||
? Object.values(insurerRating).filter(
|
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const insurerAvg =
|
|
||||||
insurerValues.length > 0
|
|
||||||
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const userValues = userRating
|
|
||||||
? [
|
|
||||||
userRating.progressSpeed,
|
|
||||||
userRating.registrationEase,
|
|
||||||
userRating.overallEvaluation,
|
|
||||||
].filter((val) => typeof val === "number" && !isNaN(val))
|
|
||||||
: [];
|
|
||||||
const userAvg =
|
|
||||||
userValues.length > 0
|
|
||||||
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const scores = [insurerAvg, userAvg].filter(
|
|
||||||
(v): v is number => typeof v === "number" && !isNaN(v),
|
|
||||||
);
|
|
||||||
if (scores.length === 0) return null;
|
|
||||||
return parseFloat(
|
|
||||||
(scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processRatings = (expertId: string, file: any) => {
|
|
||||||
if (!expertId) return;
|
if (!expertId) return;
|
||||||
|
|
||||||
const rating = file?.rating;
|
const rating = file?.rating;
|
||||||
const combinedScore = getCombinedFileScore(file);
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
|
|
||||||
// Aggregate overall combined score for this expert
|
|
||||||
if (combinedScore !== null) {
|
if (combinedScore !== null) {
|
||||||
if (!expertTotalRatingsMap[expertId])
|
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
|
||||||
expertTotalRatingsMap[expertId] = [];
|
|
||||||
expertTotalRatingsMap[expertId].push(combinedScore);
|
expertTotalRatingsMap[expertId].push(combinedScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep backward-compatible per-category insurer ratings
|
|
||||||
if (!rating || typeof rating !== "object") return;
|
if (!rating || typeof rating !== "object") return;
|
||||||
|
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
|
||||||
if (!expertRatingsByCategoryMap[expertId]) {
|
|
||||||
expertRatingsByCategoryMap[expertId] = {};
|
|
||||||
}
|
|
||||||
for (const [category, value] of Object.entries(rating)) {
|
for (const [category, value] of Object.entries(rating)) {
|
||||||
if (typeof value === "number" && !isNaN(value)) {
|
if (typeof value === "number" && !isNaN(value)) {
|
||||||
if (!expertRatingsByCategoryMap[expertId][category]) {
|
if (!expertRatingsByCategoryMap[expertId][category]) {
|
||||||
@@ -169,37 +165,29 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const file of blameFiles) {
|
for (const file of blameFiles) {
|
||||||
const expertId = file?.actorLocked?.actorId?.toString();
|
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
|
||||||
processRatings(expertId, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of claimFiles) {
|
for (const file of claimFiles) {
|
||||||
const expertId = file?.damageExpertReply?.actorDetail?.actorId;
|
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
|
||||||
processRatings(expertId, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allExperts = allExpertsRaw.map((expert) => {
|
const allExperts = allExpertsRaw.map((expert) => {
|
||||||
const expertIdStr = expert._id.toString();
|
const expertIdStr = expert._id.toString();
|
||||||
|
|
||||||
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
|
||||||
const overallAverageRating = totalRatings.length
|
const overallAverageRating = totalRatings.length
|
||||||
? parseFloat(
|
? parseFloat(
|
||||||
(
|
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
|
||||||
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
|
|
||||||
).toFixed(2),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const averageRatingsByCategory: Record<string, number> = {};
|
const averageRatingsByCategory: Record<string, number> = {};
|
||||||
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
|
||||||
if (ratingsByCat) {
|
if (ratingsByCat) {
|
||||||
for (const [category, values] of Object.entries(ratingsByCat)) {
|
for (const [category, values] of Object.entries(ratingsByCat)) {
|
||||||
const sum = values.reduce((a, b) => a + b, 0);
|
averageRatingsByCategory[category] = parseFloat(
|
||||||
const average = parseFloat((sum / values.length).toFixed(2));
|
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
|
||||||
averageRatingsByCategory[category] = average;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: expert._id,
|
_id: expert._id,
|
||||||
fullName: `${expert.firstName} ${expert.lastName}`,
|
fullName: `${expert.firstName} ${expert.lastName}`,
|
||||||
@@ -212,14 +200,14 @@ export class ExpertInsurerService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const start = (currentPage - 1) * countPerPage;
|
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
|
||||||
const paginated = allExperts.slice(start, start + countPerPage);
|
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
|
||||||
|
const start = (page - 1) * perPage;
|
||||||
return {
|
return {
|
||||||
total: allExperts.length,
|
total: allExperts.length,
|
||||||
page: currentPage,
|
page,
|
||||||
countPerPage,
|
countPerPage: perPage,
|
||||||
experts: paginated,
|
experts: allExperts.slice(start, start + perPage),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,510 +233,125 @@ export class ExpertInsurerService {
|
|||||||
* combined insurer + user ratings.
|
* combined insurer + user ratings.
|
||||||
*/
|
*/
|
||||||
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
async getTopFilesForClient(insurerId: string): Promise<any[]> {
|
||||||
const id = new Types.ObjectId(insurerId);
|
const claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
|
||||||
|
|
||||||
const claimFiles = await this.claimRequestManagementModel
|
|
||||||
.find(
|
|
||||||
{
|
|
||||||
userClientKey: id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
carPlate: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
rating: 1,
|
|
||||||
userRating: 1,
|
|
||||||
damageExpertReply: 1,
|
|
||||||
damageExpertReplyFinal: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const scored = claimFiles
|
const scored = claimFiles
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const insurerRating = file?.rating;
|
const combinedScore = this.getCombinedFileScore(file);
|
||||||
const userRating = file?.userRating;
|
if (combinedScore === null) return null;
|
||||||
|
return { ...file, combinedScore };
|
||||||
const insurerValues = insurerRating
|
|
||||||
? Object.values(insurerRating).filter(
|
|
||||||
(val): val is number => typeof val === "number" && !isNaN(val),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const insurerAvg =
|
|
||||||
insurerValues.length > 0
|
|
||||||
? insurerValues.reduce((a, b) => a + b, 0) / insurerValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const userValues = userRating
|
|
||||||
? [
|
|
||||||
userRating.progressSpeed,
|
|
||||||
userRating.registrationEase,
|
|
||||||
userRating.overallEvaluation,
|
|
||||||
].filter((val) => typeof val === "number" && !isNaN(val))
|
|
||||||
: [];
|
|
||||||
const userAvg =
|
|
||||||
userValues.length > 0
|
|
||||||
? userValues.reduce((a, b) => a + b, 0) / userValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const scores = [insurerAvg, userAvg].filter(
|
|
||||||
(v): v is number => typeof v === "number" && !isNaN(v),
|
|
||||||
);
|
|
||||||
if (scores.length === 0) return null;
|
|
||||||
|
|
||||||
const combinedScore =
|
|
||||||
scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
combinedScore: parseFloat(combinedScore.toFixed(2)),
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.filter((f) => f !== null);
|
.filter((f) => f !== null);
|
||||||
|
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
|
||||||
const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore);
|
|
||||||
return sorted.slice(0, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") {
|
private async assertExpertBelongsToInsurer(
|
||||||
const expertObjectId = new Types.ObjectId(expertId);
|
expertObjectId: Types.ObjectId,
|
||||||
|
insurerClientKey: string,
|
||||||
const model =
|
) {
|
||||||
role === "claim"
|
const ck = String(insurerClientKey);
|
||||||
? this.claimRequestManagementModel
|
const clientOid = new Types.ObjectId(ck);
|
||||||
: this.requestManagementModel;
|
const [experts, damageExperts] = await Promise.all([
|
||||||
const expertCollection = role === "claim" ? "damage-expert" : "expert";
|
this.expertDbService.findAll({ clientKey: ck }),
|
||||||
|
this.damageExpertDbService.findAll({ clientKey: clientOid }),
|
||||||
const results = await model.aggregate([
|
|
||||||
{ $match: { "actorLocked.actorId": expertObjectId } },
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: expertCollection,
|
|
||||||
localField: "actorLocked.actorId",
|
|
||||||
foreignField: "_id",
|
|
||||||
as: "expertInfo",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $unwind: { path: "$expertInfo", preserveNullAndEmptyArrays: true } },
|
|
||||||
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
averageRating: {
|
|
||||||
$cond: [
|
|
||||||
{ $ne: ["$rating", null] },
|
|
||||||
{
|
|
||||||
$avg: [
|
|
||||||
"$rating.collisionMethodAccuracy",
|
|
||||||
"$rating.evaluationTimeliness",
|
|
||||||
"$rating.accidentCauseAccuracy",
|
|
||||||
"$rating.guiltyVehicleIdentification",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
$project: {
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
steps: 1,
|
|
||||||
currentStep: 1,
|
|
||||||
rating: 1,
|
|
||||||
averageRating: 1,
|
|
||||||
imageRequired: 1,
|
|
||||||
expertInfo: {
|
|
||||||
_id: 1,
|
|
||||||
fullName: {
|
|
||||||
$concat: ["$expertInfo.firstName", " ", "$expertInfo.lastName"],
|
|
||||||
},
|
|
||||||
requestStats: 1,
|
|
||||||
userType: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
const id = String(expertObjectId);
|
||||||
// Process imageRequired for claim files
|
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
|
||||||
if (role === "claim") {
|
if (!ok) {
|
||||||
return await Promise.all(
|
throw new ForbiddenException(
|
||||||
results.map((file) => this.processImageRequired(file)),
|
"This expert is not registered under your insurance company.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
async getAllFilesByExpertAndRole(
|
||||||
|
expertId: string,
|
||||||
|
role: "claim" | "blame",
|
||||||
|
insurerClientKey: string,
|
||||||
|
) {
|
||||||
|
const expertObjectId = this.parseObjectId(expertId, "expert id");
|
||||||
|
await this.assertExpertBelongsToInsurer(expertObjectId, insurerClientKey);
|
||||||
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
||||||
|
|
||||||
|
if (role === "claim") {
|
||||||
|
const claims = (await this.claimCaseDbService.find({}, { lean: true })) as any[];
|
||||||
|
return claims
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
claimCaseTouchesClient(c, clientKeyStr) &&
|
||||||
|
c?.evaluation?.damageExpertReply?.actorDetail?.actorId ===
|
||||||
|
String(expertObjectId),
|
||||||
|
)
|
||||||
|
.map((c) => this.normalizeClaimCase(c));
|
||||||
|
}
|
||||||
|
const blames = (await this.blameRequestDbService.find({}, { lean: true })) as any[];
|
||||||
|
return blames
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
blameCaseTouchesClient(b, clientKeyStr) &&
|
||||||
|
String(b?.expert?.assignedExpertId ?? "") === String(expertObjectId),
|
||||||
|
)
|
||||||
|
.map((b) => this.normalizeBlameCase(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
async rateExpertOnFile(
|
async rateExpertOnFile(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
rating: FileRating,
|
rating: FileRating,
|
||||||
role: "claim" | "blame",
|
role: "claim" | "blame",
|
||||||
|
insurerClientKey: string,
|
||||||
) {
|
) {
|
||||||
const _id = new Types.ObjectId(requestId);
|
const _id = this.parseObjectId(requestId, "request id");
|
||||||
|
const clientKeyStr = String(this.getClientId(insurerClientKey));
|
||||||
// Validate each rating factor
|
for (const [key, value] of Object.entries(rating || {})) {
|
||||||
const fields = Object.entries(rating);
|
|
||||||
for (const [key, value] of fields) {
|
|
||||||
if (typeof value !== "number" || value < 0 || value > 5) {
|
if (typeof value !== "number" || value < 0 || value > 5) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`${key} must be a number between 0 and 5`);
|
||||||
`${key} must be a number between 0 and 5`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "claim") {
|
if (role === "claim") {
|
||||||
const updated = await this.claimRequestManagementModel.findByIdAndUpdate(
|
const existing = await this.claimCaseDbService.findById(_id);
|
||||||
_id,
|
if (!existing) throw new NotFoundException("Claim file not found");
|
||||||
{ $set: { rating } },
|
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||||
{ new: true },
|
throw new ForbiddenException("This claim does not belong to your organization.");
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException("Claim file not found");
|
|
||||||
}
|
}
|
||||||
|
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
|
||||||
|
$set: { "evaluation.rating": rating },
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Claim file not found");
|
||||||
return {
|
return {
|
||||||
message: "Claim expert rated successfully",
|
message: "Claim expert rated successfully",
|
||||||
updatedRating: updated.rating,
|
updatedRating: (updated as any)?.evaluation?.rating || rating,
|
||||||
requestId: updated._id,
|
requestId: updated._id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "blame") {
|
if (role === "blame") {
|
||||||
const updated = await this.requestManagementModel.findByIdAndUpdate(
|
const existing = await this.blameRequestDbService.findById(_id);
|
||||||
_id,
|
if (!existing) throw new NotFoundException("Blame file not found");
|
||||||
{ $set: { rating } },
|
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
|
||||||
{ new: true },
|
throw new ForbiddenException("This blame case does not belong to your organization.");
|
||||||
);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException("Blame file not found");
|
|
||||||
}
|
}
|
||||||
|
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
|
||||||
|
$set: { "expert.rating": rating },
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Blame file not found");
|
||||||
return {
|
return {
|
||||||
message: "Blame expert rated successfully",
|
message: "Blame expert rated successfully",
|
||||||
updatedRating: updated.rating,
|
updatedRating: (updated as any)?.expert?.rating || rating,
|
||||||
requestId: updated._id,
|
requestId: updated._id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException("Invalid role");
|
throw new BadRequestException("Invalid role");
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveAllFilesOfClient(insurerId: string) {
|
async retrieveAllFilesOfClient(insurerId: string) {
|
||||||
const id = new Types.ObjectId(insurerId);
|
const id = this.getClientId(insurerId);
|
||||||
|
const [blameFiles, claimFiles] = await Promise.all([
|
||||||
const blameFilesRaw = await this.requestManagementModel
|
this.getClientBlameFiles(id),
|
||||||
.find({
|
this.getClientClaimFiles(id),
|
||||||
"firstPartyDetails.firstPartyClient.clientId": id,
|
]);
|
||||||
blameStatus: {
|
return { blameFiles, claimFiles };
|
||||||
$nin: ["PendingForFirstParty", "PendingForSecondParty"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const claimFilesRaw = await this.claimRequestManagementModel
|
|
||||||
.find({
|
|
||||||
userClientKey: id,
|
|
||||||
}, {
|
|
||||||
_id: 1,
|
|
||||||
requestNumber: 1,
|
|
||||||
userClientKey: 1,
|
|
||||||
userId: 1,
|
|
||||||
fullName: 1,
|
|
||||||
carDetail: 1,
|
|
||||||
carPlate: 1,
|
|
||||||
claimStatus: 1,
|
|
||||||
currentStep: 1,
|
|
||||||
nextStep: 1,
|
|
||||||
carPartDamage: 1,
|
|
||||||
otherParts: 1,
|
|
||||||
sheba: 1,
|
|
||||||
nationalCodeOfInsurer: 1,
|
|
||||||
carGreenCard: 1,
|
|
||||||
aiImages: 1,
|
|
||||||
imageRequired: 1,
|
|
||||||
videoCaptureId: 1,
|
|
||||||
damageExpertReply: 1,
|
|
||||||
damageExpertReplyFinal: 1,
|
|
||||||
damageExpertResend: 1,
|
|
||||||
objection: 1,
|
|
||||||
userResendDocuments: 1,
|
|
||||||
priceDrop: 1,
|
|
||||||
requiredDocuments: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1,
|
|
||||||
rating: 1,
|
|
||||||
visitLocation: 1,
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const populatedBlameFiles = await Promise.all(
|
|
||||||
blameFilesRaw.map((file) => this.populateBlameFileLinks(file)),
|
|
||||||
);
|
|
||||||
const populatedClaimFiles = await Promise.all(
|
|
||||||
claimFilesRaw.map(async (file) => {
|
|
||||||
const populated = await this.populateClaimFileLinks(file);
|
|
||||||
return await this.processImageRequired(populated);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { blameFiles: populatedBlameFiles, claimFiles: populatedClaimFiles };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateBlameFileLinks(blameFile: any): Promise<any> {
|
|
||||||
if (!blameFile) return blameFile;
|
|
||||||
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (blameFile.firstPartyDetails?.firstPartyFile?.firstPartyVideoId) {
|
|
||||||
const videoDoc = await this.blameVideoDbService.findById(
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId.toString(),
|
|
||||||
);
|
|
||||||
if (videoDoc)
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.firstPartyVideoId =
|
|
||||||
buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
if (Array.isArray(blameFile.firstPartyDetails?.firstPartyFile?.voices)) {
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.voices =
|
|
||||||
await this.populateIdArray(
|
|
||||||
this.blameVoiceDbService,
|
|
||||||
blameFile.firstPartyDetails.firstPartyFile.voices,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (Array.isArray(blameFile.secondPartyDetails?.secondPartyFiles?.voices)) {
|
|
||||||
blameFile.secondPartyDetails.secondPartyFiles.voices =
|
|
||||||
await this.populateIdArray(
|
|
||||||
this.blameVoiceDbService,
|
|
||||||
blameFile.secondPartyDetails.secondPartyFiles.voices,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blameFile.expertResendReply) {
|
|
||||||
await this.populatePartyReplyLinks(
|
|
||||||
blameFile.expertResendReply.firstParty,
|
|
||||||
);
|
|
||||||
await this.populatePartyReplyLinks(
|
|
||||||
blameFile.expertResendReply.secondParty,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalReply =
|
|
||||||
blameFile.expertSubmitReplyFinal || blameFile.expertSubmitReply;
|
|
||||||
if (finalReply) {
|
|
||||||
await this.populateSignatureLink(finalReply.firstPartyComment);
|
|
||||||
await this.populateSignatureLink(finalReply.secondPartyComment);
|
|
||||||
}
|
|
||||||
|
|
||||||
return blameFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateClaimFileLinks(claimFile: any): Promise<any> {
|
|
||||||
if (!claimFile) return claimFile;
|
|
||||||
|
|
||||||
if (claimFile.blameFile) {
|
|
||||||
claimFile.blameFile = await this.populateBlameFileLinks(
|
|
||||||
claimFile.blameFile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (claimFile.videoCaptureId) {
|
|
||||||
const videoDoc = await this.claimVideoCaptureDbService.findById(
|
|
||||||
claimFile.videoCaptureId._id.toString(),
|
|
||||||
);
|
|
||||||
if (videoDoc) claimFile.videoCaptureId = buildFileLink(videoDoc.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claimFile.requiredDocuments) {
|
|
||||||
const documents = await this.claimRequiredDocumentDbService.findByClaimId(
|
|
||||||
claimFile._id.toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Populate with file URLs
|
|
||||||
const populatedDocuments = documents.map((doc) => ({
|
|
||||||
_id: doc._id,
|
|
||||||
documentType: doc.documentType,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
fileUrl: buildFileLink(doc.path),
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
claimFile.requiredDocuments = populatedDocuments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes imageRequired field:
|
|
||||||
* 1. Removes part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
* 2. Populates imageId fields with file links
|
|
||||||
*/
|
|
||||||
private async processImageRequired(claimFile: any): Promise<any> {
|
|
||||||
if (!claimFile || !claimFile.imageRequired) {
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageRequired = claimFile.imageRequired;
|
|
||||||
|
|
||||||
// Process aroundTheCar array
|
|
||||||
if (Array.isArray(imageRequired.aroundTheCar)) {
|
|
||||||
imageRequired.aroundTheCar = await Promise.all(
|
|
||||||
imageRequired.aroundTheCar.map(async (item: any) => {
|
|
||||||
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
if (
|
|
||||||
item?.aiReport?.distinct_damaged_parts_report?.parts &&
|
|
||||||
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
|
|
||||||
) {
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts =
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts.map(
|
|
||||||
(part: any) => {
|
|
||||||
const { part_segments, ...partWithoutSegments } = part;
|
|
||||||
return partWithoutSegments;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate imageId with file link
|
|
||||||
if (item?.imageId) {
|
|
||||||
try {
|
|
||||||
const imageDoc = await this.damageImageDbService.findOne(
|
|
||||||
item.imageId.toString(),
|
|
||||||
);
|
|
||||||
if (imageDoc && imageDoc.path) {
|
|
||||||
item.imageId = buildFileLink(imageDoc.path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to populate imageId for aroundTheCar item: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process selectPartOfCar array
|
|
||||||
if (Array.isArray(imageRequired.selectPartOfCar)) {
|
|
||||||
imageRequired.selectPartOfCar = await Promise.all(
|
|
||||||
imageRequired.selectPartOfCar.map(async (item: any) => {
|
|
||||||
// Remove part_segments from aiReport.distinct_damaged_parts_report.parts[]
|
|
||||||
if (
|
|
||||||
item?.aiReport?.distinct_damaged_parts_report?.parts &&
|
|
||||||
Array.isArray(item.aiReport.distinct_damaged_parts_report.parts)
|
|
||||||
) {
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts =
|
|
||||||
item.aiReport.distinct_damaged_parts_report.parts.map(
|
|
||||||
(part: any) => {
|
|
||||||
const { part_segments, ...partWithoutSegments } = part;
|
|
||||||
return partWithoutSegments;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate imageId with file link
|
|
||||||
if (item?.imageId) {
|
|
||||||
try {
|
|
||||||
const imageDoc = await this.damageImageDbService.findOne(
|
|
||||||
item.imageId.toString(),
|
|
||||||
);
|
|
||||||
if (imageDoc && imageDoc.path) {
|
|
||||||
item.imageId = buildFileLink(imageDoc.path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to populate imageId for selectPartOfCar item: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populatePartyReplyLinks(partyReply: any) {
|
|
||||||
if (!partyReply) return;
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
if (partyReply.voice) {
|
|
||||||
const voiceDoc = await this.blameVoiceDbService.findById(
|
|
||||||
partyReply.voice.toString(),
|
|
||||||
);
|
|
||||||
if (voiceDoc) partyReply.voice = buildFileLink(voiceDoc.path);
|
|
||||||
}
|
|
||||||
if (partyReply.documents) {
|
|
||||||
for (const docType in partyReply.documents) {
|
|
||||||
const docId = partyReply.documents[docType];
|
|
||||||
if (docId) {
|
|
||||||
const doc = await this.blameDocumentDbService.findById(
|
|
||||||
docId.toString(),
|
|
||||||
);
|
|
||||||
if (doc) partyReply.documents[docType] = buildFileLink(doc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateSignatureLink(comment: any) {
|
|
||||||
if (comment?.signDetail?.fileId) {
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
const signDoc = await this.userSignDbService.findById(
|
|
||||||
comment.signDetail.fileId.toString(),
|
|
||||||
);
|
|
||||||
if (signDoc)
|
|
||||||
(comment.signDetail as any).fileUrl = buildFileLink(signDoc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async populateIdArray(dbService: any, ids: any[]): Promise<string[]> {
|
|
||||||
return Promise.all(
|
|
||||||
ids.map(async (id) => {
|
|
||||||
if (!id) return id;
|
|
||||||
|
|
||||||
if (typeof id === "object" && id.path) {
|
|
||||||
return buildFileLink(id.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idString = id.toString();
|
|
||||||
if (idString.includes("[object Object]")) {
|
|
||||||
this.logger.warn(
|
|
||||||
"Invalid object found in ID array, skipping population.",
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// --- FIX: Consistently use findById ---
|
|
||||||
const doc = await dbService.findById(idString);
|
|
||||||
return doc ? buildFileLink(doc.path) : id;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to populate ID: ${idString}`, error);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
|
||||||
const clientId = new Types.ObjectId(clientKey);
|
const clientId = this.getClientId(clientKey);
|
||||||
|
|
||||||
const existingBranch = await this.branchDbService.findOne({
|
const existingBranch = await this.branchDbService.findOne({
|
||||||
clientKey: clientId,
|
clientKey: clientId,
|
||||||
@@ -778,19 +381,8 @@ export class ExpertInsurerService {
|
|||||||
* - Number of files created in the current month
|
* - Number of files created in the current month
|
||||||
*/
|
*/
|
||||||
async getExpertStatisticsReport(actor: any) {
|
async getExpertStatisticsReport(actor: any) {
|
||||||
const { clientKey } = actor;
|
const clientObjectId = this.getClientId(actor);
|
||||||
if (!clientKey) {
|
const claimFiles = await this.getClientClaimFiles(clientObjectId);
|
||||||
throw new BadRequestException("Client key is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientObjectId = new Types.ObjectId(clientKey);
|
|
||||||
|
|
||||||
// Get all claim files for this client
|
|
||||||
const claimFiles = await this.claimRequestManagementModel
|
|
||||||
.find({
|
|
||||||
userClientKey: clientObjectId,
|
|
||||||
})
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
// Calculate current month date range
|
// Calculate current month date range
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -907,6 +499,6 @@ export class ExpertInsurerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async retrieveInsuranceBranches(insuranceId: string) {
|
async retrieveInsuranceBranches(insuranceId: string) {
|
||||||
return await this.branchDbService.findAll(insuranceId);
|
return this.branchDbService.findAll(insuranceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/helpers/tenant-scope.ts
Normal file
66
src/helpers/tenant-scope.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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 {
|
||||||
|
return String(doc?.owner?.userClientKey ?? "") === String(clientKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
BadGatewayException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import axios from "axios";
|
|
||||||
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
import { LookupDbService } from "./entities/db-service/lookup.db.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LookupsService {
|
export class LookupsService {
|
||||||
private readonly logger = new Logger(LookupsService.name);
|
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
|
constructor(private readonly lookupDbService: LookupDbService) {}
|
||||||
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,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1: Get appToken from GetAppToken API
|
* Returns stored `response` for a lookup document by `name` in the lookups collection.
|
||||||
* The token is returned in the response header 'appToken'
|
|
||||||
*/
|
*/
|
||||||
private async getAppToken(): Promise<string> {
|
async getLookup(lookupName: string): Promise<any> {
|
||||||
try {
|
const doc = await this.lookupDbService.findOne({ name: lookupName });
|
||||||
const requestHeaders: any = {
|
if (!doc) {
|
||||||
appname: this.APP_NAME,
|
this.logger.warn(`Lookup not found in database: ${lookupName}`);
|
||||||
secret: this.SECRET,
|
throw new NotFoundException(`Lookup "${lookupName}" not found`);
|
||||||
"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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
async getAccidentCulpritType(): Promise<any> {
|
||||||
return await this.getLookup("accident-culprit-type", "code-list");
|
return await this.getLookup("accident-culprit-type");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,28 @@
|
|||||||
export class DamageExpertAllRequestsCountReportDtoRs {
|
/**
|
||||||
all: number;
|
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
|
||||||
UnChecked: number;
|
* plus `all` = total matching documents for the tenant.
|
||||||
CheckedRequest: number;
|
*/
|
||||||
CheckAgain: number;
|
export class BlameCaseStatusCountReportDtoRs {
|
||||||
WaitingForUserToResend: number;
|
[key: string]: number;
|
||||||
CloseRequest: number;
|
|
||||||
WaitingForUserCompleted: number;
|
|
||||||
InPersonVisit: number;
|
|
||||||
|
|
||||||
constructor(report: any) {
|
constructor(report: Record<string, number>) {
|
||||||
this.all = report.all;
|
Object.assign(this, report);
|
||||||
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 {
|
export class ClaimCaseStatusCountReportDtoRs {
|
||||||
all: number;
|
[key: string]: number;
|
||||||
UnChecked: number;
|
|
||||||
CheckedRequest: number;
|
|
||||||
CheckAgain: number;
|
|
||||||
WaitingForUserToResend: number;
|
|
||||||
CloseRequest: number;
|
|
||||||
WaitingForUserCompleted: number;
|
|
||||||
|
|
||||||
constructor(report: any) {
|
constructor(report: Record<string, number>) {
|
||||||
this.all = report.all;
|
Object.assign(this, report);
|
||||||
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 {
|
export class CompanyAllRequestsCountReportDtoRs {
|
||||||
blame: object;
|
blame: Record<string, number>;
|
||||||
claim: object;
|
claim: Record<string, number>;
|
||||||
|
|
||||||
constructor(blame: object, claim: object) {
|
constructor(blame: Record<string, number>, claim: Record<string, number>) {
|
||||||
this.blame = blame;
|
this.blame = blame;
|
||||||
this.claim = claim;
|
this.claim = claim;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||||
import { ClientModule } from "src/client/client.module";
|
|
||||||
import { ReportsController } from "./reports.controller";
|
import { ReportsController } from "./reports.controller";
|
||||||
import { ReportsService } from "./reports.service";
|
import { ReportsService } from "./reports.service";
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service";
|
|||||||
imports: [
|
imports: [
|
||||||
RequestManagementModule,
|
RequestManagementModule,
|
||||||
ClaimRequestManagementModule,
|
ClaimRequestManagementModule,
|
||||||
ClientModule,
|
|
||||||
],
|
],
|
||||||
controllers: [ReportsController],
|
controllers: [ReportsController],
|
||||||
providers: [ReportsService],
|
providers: [ReportsService],
|
||||||
|
|||||||
@@ -1,413 +1,243 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
|
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
|
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||||
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
|
import { requireActorClientKey } from "src/helpers/tenant-scope";
|
||||||
import { UserType } from "src/Types&Enums/userType.enum";
|
|
||||||
import {
|
import {
|
||||||
|
BlameCaseStatusCountReportDtoRs,
|
||||||
|
ClaimCaseStatusCountReportDtoRs,
|
||||||
CompanyAllRequestsCountReportDtoRs,
|
CompanyAllRequestsCountReportDtoRs,
|
||||||
DamageExpertAllRequestsCountReportDtoRs,
|
|
||||||
ExpertAllRequestsCountReportDtoRs,
|
|
||||||
} from "./dto/reports.dto";
|
} from "./dto/reports.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
private readonly logger = new Logger(ReportsService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementDbService: RequestManagementDbService,
|
private readonly blameRequestDbService: BlameRequestDbService,
|
||||||
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
|
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||||
private readonly clientDbService: ClientDbService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAllCheckedRequestsCountFn(role: string, client: string) {
|
private clientObjectId(client: string | undefined): Types.ObjectId {
|
||||||
const statuses = ["UnChecked", "CheckedRequest"];
|
const ck = requireActorClientKey({ clientKey: client });
|
||||||
const data: Record<string, number> = { all: 0 };
|
return new Types.ObjectId(ck);
|
||||||
const clientId = new Types.ObjectId(client);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDateFilteredRequestByStatus(
|
private isBlameForClient(r: any, client: Types.ObjectId): boolean {
|
||||||
role: string,
|
const idStr = String(client);
|
||||||
client: string,
|
return (r?.parties || []).some(
|
||||||
start: Date,
|
(p) => String(p?.person?.clientId || "") === idStr,
|
||||||
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 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 wasHandledByActor(request: any, actorSub: string): boolean {
|
|
||||||
type ActorCheckerEntry = { CheckedRequest?: { actorId: string } };
|
|
||||||
const actorChecker = request.actorsChecker as ActorCheckerEntry[];
|
|
||||||
|
|
||||||
if (!Array.isArray(actorChecker)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingEntry = actorChecker.find(
|
|
||||||
(entry) => String(entry?.CheckedRequest?.actorId) === actorSub,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return !!matchingEntry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private isClaimForClient(r: any, client: Types.ObjectId): boolean {
|
||||||
* Filters claim requests using the same logic as expert-claim service
|
return String(r?.owner?.userClientKey || "") === String(client);
|
||||||
* to ensure consistency between endpoints
|
}
|
||||||
*/
|
|
||||||
private async filterClaimRequestsForExpert(
|
|
||||||
requests: any[],
|
|
||||||
actor: any,
|
|
||||||
): Promise<any[]> {
|
|
||||||
const filteredRequests = [];
|
|
||||||
|
|
||||||
for (const r of requests) {
|
private countBlameByCaseStatus(
|
||||||
// For expert-initiated blame files, only show to the initiating expert
|
blames: any[],
|
||||||
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
|
clientId: Types.ObjectId,
|
||||||
if (String(r.blameFile.initiatedBy) !== actor.sub) {
|
): Record<string, number> {
|
||||||
continue; // Skip if not the initiating expert
|
const out: Record<string, number> = { all: 0 };
|
||||||
}
|
for (const s of Object.values(CaseStatus)) {
|
||||||
// Expert-initiated claim files are always visible to the initiating expert
|
out[s] = 0;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (const r of blames) {
|
||||||
return filteredRequests;
|
if (!this.isBlameForClient(r, clientId)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsCountByRole(role: string, client: string, actor?: any) {
|
private countClaimByCaseStatus(
|
||||||
|
claims: any[],
|
||||||
|
clientId: Types.ObjectId,
|
||||||
|
): Record<string, number> {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) {
|
||||||
|
out[s] = 0;
|
||||||
|
}
|
||||||
|
for (const r of claims) {
|
||||||
|
if (!this.isClaimForClient(r, clientId)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllRequestsCountByRole(role: string, client: string | undefined) {
|
||||||
|
const clientId = this.clientObjectId(client);
|
||||||
|
const [blames, claims] = await Promise.all([
|
||||||
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (role === "expert") {
|
if (role === "expert") {
|
||||||
const statuses = Object.values(ReqBlameStatus);
|
return this.countBlameByCaseStatus(blames as any[], clientId);
|
||||||
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") {
|
if (role === "damage_expert") {
|
||||||
const statuses = Object.values(ReqClaimStatus);
|
return this.countClaimByCaseStatus(claims as any[], clientId);
|
||||||
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") {
|
if (role === "company") {
|
||||||
const blameStatuses = Object.values(ReqBlameStatus);
|
return {
|
||||||
const claimStatuses = Object.values(ReqClaimStatus);
|
blame: this.countBlameByCaseStatus(blames as any[], clientId),
|
||||||
|
claim: this.countClaimByCaseStatus(claims as any[], clientId),
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportCount(actor, client) {
|
/**
|
||||||
if (actor.role === "damage_expert") {
|
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
|
||||||
// Pass actor to apply filtering logic for damage_expert
|
* Company gets both blame and claim maps; expert / damage_expert get one map.
|
||||||
const data = await this.getAllRequestsCountByRole(
|
*/
|
||||||
actor.role,
|
async getDateFilteredRequestByStatus(
|
||||||
client,
|
role: string,
|
||||||
actor,
|
client: string | undefined,
|
||||||
);
|
start: Date,
|
||||||
return new DamageExpertAllRequestsCountReportDtoRs(data);
|
end: Date,
|
||||||
} else if (actor.role === "expert") {
|
) {
|
||||||
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
const clientId = this.clientObjectId(client);
|
||||||
return new ExpertAllRequestsCountReportDtoRs(data);
|
const [blames, claims] = await Promise.all([
|
||||||
} else {
|
this.blameRequestDbService.find({}, { lean: true }),
|
||||||
// company
|
this.claimCaseDbService.find({}, { lean: true }),
|
||||||
const damageExpertData = await this.getAllRequestsCountByRole(
|
]);
|
||||||
"damage_expert",
|
|
||||||
client,
|
const inRange = (r: any) => {
|
||||||
);
|
const createdAt = new Date(r.createdAt);
|
||||||
const expertData = await this.getAllRequestsCountByRole("expert", client);
|
return createdAt >= start && createdAt <= end;
|
||||||
return new CompanyAllRequestsCountReportDtoRs(
|
};
|
||||||
expertData,
|
|
||||||
damageExpertData,
|
if (role === "company") {
|
||||||
);
|
const blameOut: Record<string, number> = { all: 0 };
|
||||||
|
const claimOut: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(CaseStatus)) blameOut[s] = 0;
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) claimOut[s] = 0;
|
||||||
|
|
||||||
|
for (const r of blames as any[]) {
|
||||||
|
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in blameOut) blameOut[st]++;
|
||||||
|
else blameOut[st] = (blameOut[st] ?? 0) + 1;
|
||||||
|
blameOut.all++;
|
||||||
|
}
|
||||||
|
for (const r of claims as any[]) {
|
||||||
|
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in claimOut) claimOut[st]++;
|
||||||
|
else claimOut[st] = (claimOut[st] ?? 0) + 1;
|
||||||
|
claimOut.all++;
|
||||||
|
}
|
||||||
|
return { blame: blameOut, claim: claimOut };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "expert") {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(CaseStatus)) out[s] = 0;
|
||||||
|
for (const r of blames as any[]) {
|
||||||
|
if (!this.isBlameForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "damage_expert") {
|
||||||
|
const out: Record<string, number> = { all: 0 };
|
||||||
|
for (const s of Object.values(ClaimCaseStatus)) out[s] = 0;
|
||||||
|
for (const r of claims as any[]) {
|
||||||
|
if (!this.isClaimForClient(r, clientId) || !inRange(r)) continue;
|
||||||
|
const st = r?.status as string;
|
||||||
|
if (st in out) out[st]++;
|
||||||
|
else out[st] = (out[st] ?? 0) + 1;
|
||||||
|
out.all++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException("Unsupported role for reports");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllRequestsReportPerMonth(actor, client) {
|
async getAllRequestsReportCount(actor: any, client: string | undefined) {
|
||||||
const result = [];
|
requireActorClientKey(actor);
|
||||||
|
|
||||||
|
if (actor.role === "damage_expert") {
|
||||||
|
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
||||||
|
return new ClaimCaseStatusCountReportDtoRs(data as Record<string, number>);
|
||||||
|
}
|
||||||
|
if (actor.role === "expert") {
|
||||||
|
const data = await this.getAllRequestsCountByRole(actor.role, client);
|
||||||
|
return new BlameCaseStatusCountReportDtoRs(data as Record<string, number>);
|
||||||
|
}
|
||||||
|
const companyPayload = await this.getAllRequestsCountByRole(
|
||||||
|
"company",
|
||||||
|
client,
|
||||||
|
);
|
||||||
|
return new CompanyAllRequestsCountReportDtoRs(
|
||||||
|
(companyPayload as { blame: Record<string, number> }).blame,
|
||||||
|
(companyPayload as { claim: Record<string, number> }).claim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllRequestsReportPerMonth(actor: any, client: string | undefined) {
|
||||||
|
requireActorClientKey(actor);
|
||||||
|
const result: any[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (actor.role === "company") {
|
for (let i = 0; i < 5; i++) {
|
||||||
for (let i = 0; i < 5; i++) {
|
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const monthEnd = new Date(
|
||||||
const monthEnd = new Date(
|
now.getFullYear(),
|
||||||
now.getFullYear(),
|
now.getMonth() - i + 1,
|
||||||
now.getMonth() - i + 1,
|
0,
|
||||||
0,
|
23,
|
||||||
23,
|
59,
|
||||||
59,
|
59,
|
||||||
59,
|
);
|
||||||
);
|
const label = monthStart.toLocaleDateString("fa-IR", {
|
||||||
const label = monthStart.toLocaleDateString("fa-IR", {
|
month: "long",
|
||||||
month: "long",
|
year: "numeric",
|
||||||
year: "numeric",
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const blameData = await this.getDateFilteredRequestByStatus(
|
let data: any;
|
||||||
"expert",
|
if (actor.role === "company") {
|
||||||
|
data = await this.getDateFilteredRequestByStatus(
|
||||||
|
"company",
|
||||||
client,
|
client,
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
const claimData = await this.getDateFilteredRequestByStatus(
|
} else {
|
||||||
"damage_expert",
|
data = await this.getDateFilteredRequestByStatus(
|
||||||
client,
|
|
||||||
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,
|
actor.role,
|
||||||
client,
|
client,
|
||||||
monthStart,
|
monthStart,
|
||||||
monthEnd,
|
monthEnd,
|
||||||
);
|
);
|
||||||
result.unshift({
|
|
||||||
stDate: monthStart,
|
|
||||||
enDate: monthEnd,
|
|
||||||
faLabel: label,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
result.unshift({
|
||||||
|
stDate: monthStart,
|
||||||
|
enDate: monthEnd,
|
||||||
|
faLabel: label,
|
||||||
|
data,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllCheckedRequestsCount(actor, client) {
|
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
|
||||||
if (actor.role === "damage_expert" || actor.role === "expert") {
|
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
|
||||||
return this.getAllCheckedRequestsCountFn(actor.role, client);
|
return this.getAllRequestsReportCount(actor, client);
|
||||||
} else {
|
|
||||||
const blame = await this.getAllCheckedRequestsCountFn("expert", client);
|
|
||||||
const claim = await this.getAllCheckedRequestsCountFn(
|
|
||||||
"damage_expert",
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
return { blame, claim };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DescriptionDto {
|
export class DescriptionDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: "Accident description (required for all types)" })
|
||||||
desc: string;
|
desc: string;
|
||||||
|
|
||||||
// CAR_BODY specific fields
|
@ApiPropertyOptional({
|
||||||
@ApiPropertyOptional({
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
description: "Date of accident (for CAR_BODY type only)",
|
example: "2025-12-08",
|
||||||
example: "2025-12-08"
|
|
||||||
})
|
})
|
||||||
accidentDate?: Date;
|
accidentDate?: Date;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: "Time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: "14:30"
|
example: "14:30",
|
||||||
})
|
})
|
||||||
accidentTime?: string;
|
accidentTime?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: WeatherCondition,
|
enum: WeatherCondition,
|
||||||
description: "Weather condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: WeatherCondition.CLEAR
|
example: WeatherCondition.CLEAR,
|
||||||
})
|
})
|
||||||
weatherCondition?: WeatherCondition;
|
weatherCondition?: WeatherCondition;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: RoadCondition,
|
enum: RoadCondition,
|
||||||
description: "Road condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: RoadCondition.DRY
|
example: RoadCondition.DRY,
|
||||||
})
|
})
|
||||||
roadCondition?: RoadCondition;
|
roadCondition?: RoadCondition;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: LightCondition,
|
enum: LightCondition,
|
||||||
description: "Light condition at time of accident (for CAR_BODY type only)",
|
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
|
||||||
example: LightCondition.DAYLIGHT
|
example: LightCondition.DAYLIGHT,
|
||||||
})
|
})
|
||||||
lightCondition?: LightCondition;
|
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 {
|
export class FirstPartyDetail {
|
||||||
// @ApiProperty({ type: String })
|
// @ApiProperty({ type: String })
|
||||||
firstPartyId?: Types.ObjectId;
|
firstPartyId?: Types.ObjectId;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import { LocationDto } from "./create-request-management.dto";
|
|
||||||
import { DescriptionDto } from "./create-request-management.dto";
|
import { DescriptionDto } from "./create-request-management.dto";
|
||||||
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
|
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
firstPartyPlate: AddPlateDto;
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
firstPartyLocation: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
firstPartyDescription: 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 { ApiProperty } from "@nestjs/swagger";
|
||||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||||
import { LocationDto } from "./create-request-management.dto";
|
|
||||||
import { DescriptionDto } from "./create-request-management.dto";
|
import { DescriptionDto } from "./create-request-management.dto";
|
||||||
import { InitialFormDto } from "./create-request-management.dto";
|
import { InitialFormDto } from "./create-request-management.dto";
|
||||||
|
|
||||||
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
plate: AddPlateDto;
|
plate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
location: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
description: DescriptionDto;
|
description: DescriptionDto;
|
||||||
}
|
}
|
||||||
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
|
|||||||
@ApiProperty({ type: AddPlateDto })
|
@ApiProperty({ type: AddPlateDto })
|
||||||
firstPartyPlate: AddPlateDto;
|
firstPartyPlate: AddPlateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: LocationDto })
|
|
||||||
firstPartyLocation: LocationDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: DescriptionDto })
|
@ApiProperty({ type: DescriptionDto })
|
||||||
firstPartyDescription: 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";
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
export class UserResendResponseDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
|
|||||||
description: "Whether the user has completed uploading all requested items",
|
description: "Whether the user has completed uploading all requested items",
|
||||||
})
|
})
|
||||||
completed: boolean;
|
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 {
|
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 })
|
@Prop({ type: AccidentClassificationSchema })
|
||||||
classification?: AccidentClassification;
|
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);
|
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);
|
||||||
@@ -1,19 +1,27 @@
|
|||||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
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 { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||||
import { Party, PartySchema } from "./partyRole.enum";
|
import { Party, PartySchema } from "./partyRole.enum";
|
||||||
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
|
|
||||||
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
|
||||||
import { Workflow, WorkflowSchema } from "./workflow.type";
|
import { Workflow, WorkflowSchema } from "./workflow.type";
|
||||||
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
|
||||||
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
|
||||||
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.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 })
|
@Schema({ _id: false })
|
||||||
export class BlameRequestSnapshot {
|
export class BlameRequestSnapshot {
|
||||||
@Prop({ type: AccidentInfoSchema })
|
|
||||||
accident?: AccidentInfo;
|
|
||||||
|
|
||||||
@Prop({ type: [PartySchema], default: [] })
|
@Prop({ type: [PartySchema], default: [] })
|
||||||
parties?: Party[];
|
parties?: Party[];
|
||||||
}
|
}
|
||||||
@@ -44,9 +52,6 @@ export class BlameRequest {
|
|||||||
|
|
||||||
@Prop({ type: WorkflowSchema })
|
@Prop({ type: WorkflowSchema })
|
||||||
workflow?: Workflow;
|
workflow?: Workflow;
|
||||||
|
|
||||||
@Prop({ type: AccidentInfoSchema })
|
|
||||||
accident?: AccidentInfo;
|
|
||||||
|
|
||||||
@Prop({ type: [PartySchema], default: [] })
|
@Prop({ type: [PartySchema], default: [] })
|
||||||
parties: Party[];
|
parties: Party[];
|
||||||
@@ -57,12 +62,40 @@ export class BlameRequest {
|
|||||||
@Prop({ type: [HistoryEventSchema], default: [] })
|
@Prop({ type: [HistoryEventSchema], default: [] })
|
||||||
history: HistoryEvent[];
|
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).
|
* Optional snapshot structure (kept for future use / read-optimized copies).
|
||||||
* Source of truth remains the top-level fields of this document.
|
* Source of truth remains the top-level fields of this document.
|
||||||
*/
|
*/
|
||||||
@Prop({ type: BlameRequestSnapshotSchema, required: false })
|
@Prop({ type: BlameRequestSnapshotSchema, required: false })
|
||||||
snapshot?: BlameRequestSnapshot;
|
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>;
|
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export class PartyResendRequest {
|
|||||||
@Prop({ type: [String], default: [] })
|
@Prop({ type: [String], default: [] })
|
||||||
requestedItems: string[]; // Array of ResendItemType values
|
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()
|
@Prop()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@@ -75,6 +79,20 @@ export class PartyResendRequest {
|
|||||||
|
|
||||||
@Prop({ type: Date })
|
@Prop({ type: Date })
|
||||||
completedAt?: 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);
|
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
|
||||||
|
|
||||||
@@ -89,6 +107,12 @@ export class ExpertResend {
|
|||||||
|
|
||||||
@Prop({ type: Types.ObjectId })
|
@Prop({ type: Types.ObjectId })
|
||||||
requestedByExpertId?: Types.ObjectId;
|
requestedByExpertId?: Types.ObjectId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Unused for resend UI; clients use per-party requestedItems + inputKind.
|
||||||
|
*/
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
combinedResubmitStepKeys?: string[];
|
||||||
}
|
}
|
||||||
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);
|
export const ExpertResendSchema = SchemaFactory.createForClass(ExpertResend);
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export class Insurance {
|
|||||||
@Prop() financialCeiling?: string;
|
@Prop() financialCeiling?: string;
|
||||||
@Prop({ type: [String] })
|
@Prop({ type: [String] })
|
||||||
coverages?: 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);
|
export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
|
||||||
|
|
||||||
@@ -87,6 +97,13 @@ export class PartyStatement {
|
|||||||
|
|
||||||
@Prop({ type: String })
|
@Prop({ type: String })
|
||||||
description?: 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);
|
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
|
||||||
|
|
||||||
@@ -130,6 +147,13 @@ export class Party {
|
|||||||
@Prop({ type: PersonSchema })
|
@Prop({ type: PersonSchema })
|
||||||
person: Person;
|
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).
|
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export enum CreationMethod {
|
|||||||
export enum FilledBy {
|
export enum FilledBy {
|
||||||
CUSTOMER = "customer",
|
CUSTOMER = "customer",
|
||||||
EXPERT = "expert",
|
EXPERT = "expert",
|
||||||
|
REGISTRAR = "registrar",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExpertLinkInfo {
|
export class ExpertLinkInfo {
|
||||||
|
|||||||
545
src/request-management/expert-initiated.v2.controller.ts
Normal file
545
src/request-management/expert-initiated.v2.controller.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { extname } from "node:path";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Get,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiConsumes,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||||
|
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||||
|
import { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||||
|
import { ExpertCompleteThirdPartyFormV2Dto } from "./dto/expert-complete-third-party-form.v2.dto";
|
||||||
|
import { ExpertCompleteCarBodyFormV2Dto } from "./dto/expert-complete-car-body-form.v2.dto";
|
||||||
|
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||||
|
import { ExpertCompleteClaimDataDto } from "./dto/expert-complete-claim-data.dto";
|
||||||
|
import { ExpertUploadPartySignatureDto } from "./dto/expert-upload-party-signature.dto";
|
||||||
|
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||||
|
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||||
|
import { ExpertCompleteLocationV2Dto } from "./dto/expert-complete-location.v2.dto";
|
||||||
|
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||||
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 expert-initiated blame API: uses BlameRequest (workflow) model.
|
||||||
|
* Field experts create files via LINK (send link to user) or IN_PERSON (expert fills on-site).
|
||||||
|
* Only the initiating field expert can see/review these files.
|
||||||
|
*/
|
||||||
|
@ApiTags("expert-initiated-blame (v2)")
|
||||||
|
@Controller("v2/expert-initiated-blame")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.FIELD_EXPERT)
|
||||||
|
export class ExpertInitiatedV2Controller {
|
||||||
|
constructor(
|
||||||
|
private readonly requestManagementService: RequestManagementService,
|
||||||
|
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("my-files")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] List my expert-initiated blame files",
|
||||||
|
description:
|
||||||
|
"Returns all BlameRequest (workflow) files created by the current field expert.",
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "List of expert-initiated files" })
|
||||||
|
async getMyFilesV2(@CurrentUser() expert: any) {
|
||||||
|
return this.requestManagementService.getMyExpertInitiatedFilesV2(expert);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("blame/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Get one expert-initiated blame request",
|
||||||
|
description: "Returns a single blame request. Only the initiating field expert can access.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "Blame request" })
|
||||||
|
async getBlameRequestV2(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getBlameRequestV2(requestId, expert);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("create")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Create expert-initiated blame file",
|
||||||
|
description:
|
||||||
|
"Creates a BlameRequest with workflow. LINK: parties identified by phone, expert sends link. IN_PERSON: expert fills form later.",
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
type: CreateExpertInitiatedFileDto,
|
||||||
|
examples: {
|
||||||
|
thirdPartyInPerson: {
|
||||||
|
summary: "Third-party, IN_PERSON (both parties)",
|
||||||
|
description:
|
||||||
|
"Expert fills form on-site for both parties.",
|
||||||
|
value: {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
secondPartyPhoneNumber: "09187654321",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thirdPartyLink: {
|
||||||
|
summary: "Third-party, LINK",
|
||||||
|
description:
|
||||||
|
"Expert sends link to first and second party by phone.",
|
||||||
|
value: {
|
||||||
|
type: "THIRD_PARTY",
|
||||||
|
creationMethod: "LINK",
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
carBodyInPerson: {
|
||||||
|
summary: "Car-body, IN_PERSON",
|
||||||
|
description: "Expert fills form on-site for single party (car body).",
|
||||||
|
value: {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
creationMethod: "IN_PERSON",
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
carBodyLink: {
|
||||||
|
summary: "Car-body, LINK",
|
||||||
|
description:
|
||||||
|
"Expert sends link to party by phone. Only first party phone needed.",
|
||||||
|
value: {
|
||||||
|
type: "CAR_BODY",
|
||||||
|
creationMethod: "LINK",
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: "File created",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
requestId: { type: "string" },
|
||||||
|
publicId: { type: "string" },
|
||||||
|
linkUrl: { type: "string", description: "Present for LINK method" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async createV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Body() dto: CreateExpertInitiatedFileDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.createExpertInitiatedBlameV2(
|
||||||
|
expert,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("send-link/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Send blame link to party/parties (LINK)",
|
||||||
|
description:
|
||||||
|
"For expert-initiated LINK files only. Sends the blame link to the first party (and second party for THIRD_PARTY). SMS delivery is mocked for now; first party opens the link and fills the form via the normal flow. Call after create when creationMethod is LINK.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: "Link sent (mocked); recipients can open the link to fill the form",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sent: { type: "boolean" },
|
||||||
|
linkUrl: { type: "string" },
|
||||||
|
sentTo: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
role: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||||
|
phoneNumber: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async sendLinkV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.sendLinkV2(expert, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("send-party-otps/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Send OTP to party/parties (IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"Sends OTP via SMS to first party (and second party for THIRD_PARTY) using the same flow as /user/send-otp. Parties receive the code; collect it from them and call verify-party-otps. Call this before filling the blame form.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({ type: SendPartyOtpsDto })
|
||||||
|
@ApiResponse({ status: 200, description: "OTP(s) sent; collect codes and call verify-party-otps" })
|
||||||
|
async sendPartyOtpsV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: SendPartyOtpsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.sendPartyOtpsV2(expert, requestId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("verify-party-otps/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Verify party OTPs (IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"After send-party-otps, parties receive SMS. They tell you the code; submit it here. Required before complete-blame-data.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({ type: VerifyPartyOtpsDto })
|
||||||
|
@ApiResponse({ status: 200, description: "OTPs verified; expert can proceed to complete-blame-data" })
|
||||||
|
async verifyPartyOtpsV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: VerifyPartyOtpsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.verifyPartyOtpsV2(expert, requestId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("complete-blame-data/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Submit all blame data (IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"For IN_PERSON files only. Send THIRD_PARTY or CAR_BODY form EXCEPT location. Use one shared expertDescription (not per-party descriptions). After this, call add-locations once to submit scene lat/lon and move workflow to WAITING_FOR_SIGNATURES.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Choose THIRD_PARTY or CAR_BODY example according to the file type. IN_PERSON v2 uses one shared expertDescription for the scene.",
|
||||||
|
examples: {
|
||||||
|
THIRD_PARTY: {
|
||||||
|
summary: "THIRD_PARTY",
|
||||||
|
description: "Use when the blame file type is THIRD_PARTY",
|
||||||
|
value: {
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
firstPartyInitialForm: {
|
||||||
|
expertOpinion: false,
|
||||||
|
imDamaged: false,
|
||||||
|
imGuilty: true,
|
||||||
|
},
|
||||||
|
firstPartyPlate: {
|
||||||
|
nationalCodeOfInsurer: "",
|
||||||
|
nationalCodeOfDriver: "",
|
||||||
|
insurerLicense: "",
|
||||||
|
driverLicense: "",
|
||||||
|
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
|
||||||
|
driverIsInsurer: true,
|
||||||
|
isNewCar: false,
|
||||||
|
userNoCertificate: false,
|
||||||
|
insurerBirthday: 13770624,
|
||||||
|
driverBirthday: "1370-01-01",
|
||||||
|
},
|
||||||
|
expertDescription: { desc: "توضیح حادثه کارشناس (مشترک برای صحنه)" },
|
||||||
|
secondParty: {
|
||||||
|
phoneNumber: "09187654321",
|
||||||
|
initialForm: {
|
||||||
|
expertOpinion: false,
|
||||||
|
imDamaged: true,
|
||||||
|
imGuilty: false,
|
||||||
|
},
|
||||||
|
plate: {
|
||||||
|
nationalCodeOfInsurer: "",
|
||||||
|
nationalCodeOfDriver: "",
|
||||||
|
insurerLicense: "",
|
||||||
|
driverLicense: "",
|
||||||
|
plate: { leftDigits: 91, centerAlphabet: "ن", centerDigits: 174, ir: 79 },
|
||||||
|
driverIsInsurer: true,
|
||||||
|
isNewCar: false,
|
||||||
|
userNoCertificate: false,
|
||||||
|
insurerBirthday: 13700720,
|
||||||
|
driverBirthday: "1370-01-01",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
guiltyPartyPhoneNumber: "09123456789",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CAR_BODY: {
|
||||||
|
summary: "CAR_BODY",
|
||||||
|
description: "Use when the blame file type is CAR_BODY",
|
||||||
|
value: {
|
||||||
|
firstPartyPhoneNumber: "09123456789",
|
||||||
|
firstPartyInitialForm: {
|
||||||
|
expertOpinion: false,
|
||||||
|
imDamaged: false,
|
||||||
|
imGuilty: true,
|
||||||
|
},
|
||||||
|
carBodyForm: { car: true, object: false },
|
||||||
|
firstPartyPlate: {
|
||||||
|
plateId: "",
|
||||||
|
nationalCodeOfInsurer: "",
|
||||||
|
nationalCodeOfDriver: "",
|
||||||
|
insurerLicense: "",
|
||||||
|
driverLicense: "",
|
||||||
|
plate: { leftDigits: 12, centerAlphabet: "الف", centerDigits: 345, ir: 22 },
|
||||||
|
driverIsInsurer: true,
|
||||||
|
isNewCar: false,
|
||||||
|
userNoCertificate: false,
|
||||||
|
insurerBirthday: 1370,
|
||||||
|
driverBirthday: "1370-01-01",
|
||||||
|
},
|
||||||
|
expertDescription: {
|
||||||
|
desc: "توضیح حادثه",
|
||||||
|
accidentDate: "2025-01-15",
|
||||||
|
accidentTime: "14:30",
|
||||||
|
weatherCondition: "صاف",
|
||||||
|
roadCondition: "خشک",
|
||||||
|
lightCondition: "روز",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schema: { type: "object" },
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Blame form completed; next: add-locations" })
|
||||||
|
async completeBlameDataV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() formData: ExpertCompleteThirdPartyFormV2Dto | ExpertCompleteCarBodyFormV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertCompleteBlameDataV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("add-locations/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Submit one scene location for expert-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Submit one scene location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({
|
||||||
|
type: ExpertCompleteLocationV2Dto,
|
||||||
|
examples: {
|
||||||
|
scene: {
|
||||||
|
summary: "One scene location (all IN_PERSON types)",
|
||||||
|
value: {
|
||||||
|
location: { lat: 35.6892, lon: 51.389 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Locations saved; next: upload party signature(s)" })
|
||||||
|
async addLocationsV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: ExpertCompleteLocationV2Dto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAddLocationsForBlameV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
dto as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-video/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Expert uploads video for expert-initiated BlameRequest",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { file: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/video",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `expert-${file.originalname}-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiResponse({ status: 200, description: "Video uploaded" })
|
||||||
|
async uploadVideoV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertUploadVideoForBlameV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-voice/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Expert uploads voice for expert-initiated BlameRequest",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { voice: { type: "string", format: "binary" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("voice", {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/voice",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
const flname = file.originalname.split(".")[0];
|
||||||
|
callback(null, `expert-${flname}-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiResponse({ status: 200, description: "Voice uploaded" })
|
||||||
|
async uploadVoiceV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@UploadedFile() voice?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertUploadVoiceForBlameV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
voice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("add-accident-fields/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Expert adds accident fields to expert-initiated BlameRequest",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||||
|
@ApiResponse({ status: 200, description: "Accident fields added" })
|
||||||
|
async addAccidentFieldsV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() fields: ExpertAccidentFieldsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-party-signature/:requestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Expert uploads party signature (IN_PERSON)",
|
||||||
|
description:
|
||||||
|
"For IN_PERSON only. Upload a party's signature collected on-site. CAR_BODY: use partyRole FIRST once. THIRD_PARTY: upload FIRST then SECOND. When all required parties have signed, blame case completes.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["partyRole", "sign"],
|
||||||
|
properties: {
|
||||||
|
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||||
|
isAccept: { type: "boolean", default: true },
|
||||||
|
sign: { type: "string", format: "binary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("sign", {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/signs",
|
||||||
|
filename: (req, file, callback) => {
|
||||||
|
const unique = Date.now();
|
||||||
|
const ex = extname(file.originalname);
|
||||||
|
callback(null, `expert-party-${unique}${ex}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiResponse({ status: 200, description: "Signature recorded" })
|
||||||
|
async uploadPartySignatureV2(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||||
|
@UploadedFile() sign?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
const partyRole = (body.partyRole === "FIRST" || body.partyRole === "SECOND")
|
||||||
|
? body.partyRole
|
||||||
|
: ("FIRST" as const);
|
||||||
|
const isAccept = body.isAccept === false || body.isAccept === "false" ? false : true;
|
||||||
|
return this.requestManagementService.expertUploadPartySignatureV2(
|
||||||
|
expert,
|
||||||
|
requestId,
|
||||||
|
partyRole as PartyRole,
|
||||||
|
isAccept,
|
||||||
|
sign!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("create-claim-from-blame/:blameRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[V2] Create claim from expert-initiated IN_PERSON blame",
|
||||||
|
description:
|
||||||
|
"Field expert creates a claim on behalf of the damaged party. Blame must be COMPLETED (signatures collected). Then use claim v2 endpoints to fill parts, documents, and captures.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "blameRequestId", description: "Completed blame request ID" })
|
||||||
|
@ApiResponse({ status: 201, description: "Claim created" })
|
||||||
|
async createClaimFromBlame(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("blameRequestId") blameRequestId: string,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
|
||||||
|
blameRequestId,
|
||||||
|
expert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("complete-claim-data/:claimRequestId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Submit claim-needed data (expert-initiated IN_PERSON)",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "claimRequestId", description: "Claim file ID" })
|
||||||
|
@ApiBody({ type: ExpertCompleteClaimDataDto })
|
||||||
|
@ApiResponse({ status: 200, description: "Claim data updated" })
|
||||||
|
async completeClaimData(
|
||||||
|
@CurrentUser() expert: any,
|
||||||
|
@Param("claimRequestId") claimRequestId: string,
|
||||||
|
@Body() dto: ExpertCompleteClaimDataDto,
|
||||||
|
) {
|
||||||
|
return this.claimRequestManagementService.expertCompleteClaimData(
|
||||||
|
claimRequestId,
|
||||||
|
expert,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/request-management/registrar-initiated.controller.ts
Normal file
155
src/request-management/registrar-initiated.controller.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { extname } from "node:path";
|
||||||
|
import { Body, Controller, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { diskStorage } from "multer";
|
||||||
|
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 { CurrentUser } from "src/decorators/user.decorator";
|
||||||
|
import { Roles } from "src/decorators/roles.decorator";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
import { CreateRegistrarInitiatedFileDto } from "./dto/registrar-initiated.dto";
|
||||||
|
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||||
|
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||||
|
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||||
|
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||||
|
|
||||||
|
@ApiTags("registrar-initiated-blame (v1)")
|
||||||
|
@Controller("registrar-initiated-blame")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||||
|
@Roles(RoleEnum.REGISTRAR)
|
||||||
|
export class RegistrarInitiatedController {
|
||||||
|
constructor(private readonly requestManagementService: RequestManagementService) {}
|
||||||
|
|
||||||
|
@Post("create")
|
||||||
|
@ApiOperation({ summary: "Registrar creates IN_PERSON blame file" })
|
||||||
|
@ApiBody({ type: CreateRegistrarInitiatedFileDto })
|
||||||
|
create(@CurrentUser() registrar: any, @Body() dto: CreateRegistrarInitiatedFileDto) {
|
||||||
|
return this.requestManagementService.createRegistrarInitiatedBlame(registrar, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("my-files")
|
||||||
|
myFiles(@CurrentUser() registrar: any) {
|
||||||
|
return this.requestManagementService.getMyExpertInitiatedFilesV2(registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("blame/:requestId")
|
||||||
|
getBlame(@Param("requestId") requestId: string, @CurrentUser() registrar: any) {
|
||||||
|
return this.requestManagementService.getBlameRequestV2(requestId, registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("send-party-otps/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: SendPartyOtpsDto })
|
||||||
|
sendPartyOtps(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: SendPartyOtpsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.sendPartyOtpsV2(registrar, requestId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("verify-party-otps/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: VerifyPartyOtpsDto })
|
||||||
|
verifyPartyOtps(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() dto: VerifyPartyOtpsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.verifyPartyOtpsV2(registrar, requestId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("complete-blame-data/:requestId")
|
||||||
|
completeBlameData(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertCompleteBlameDataV2(registrar, requestId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-video/:requestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("file", {
|
||||||
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/video",
|
||||||
|
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadVideo(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@UploadedFile() file?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertUploadVideoForBlameV2(registrar, requestId, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-voice/:requestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("voice", {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/voice",
|
||||||
|
filename: (req, file, cb) => cb(null, `registrar-${Date.now()}${extname(file.originalname)}`),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadVoice(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@UploadedFile() voice?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertUploadVoiceForBlameV2(registrar, requestId, voice);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("add-accident-fields/:requestId")
|
||||||
|
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||||
|
addAccidentFields(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() fields: ExpertAccidentFieldsDto,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.expertAddAccidentFieldsForBlameV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-party-signature/:requestId")
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor("sign", {
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: "./files/signs",
|
||||||
|
filename: (req, file, cb) => cb(null, `registrar-party-${Date.now()}${extname(file.originalname)}`),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadPartySignature(
|
||||||
|
@CurrentUser() registrar: any,
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: { partyRole?: string; isAccept?: boolean | string },
|
||||||
|
@UploadedFile() sign?: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
const role =
|
||||||
|
body.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||||
|
const isAccept = !(body.isAccept === false || body.isAccept === "false");
|
||||||
|
return this.requestManagementService.expertUploadPartySignatureV2(
|
||||||
|
registrar,
|
||||||
|
requestId,
|
||||||
|
role,
|
||||||
|
isAccept,
|
||||||
|
sign!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ import { UsersModule } from "src/users/users.module";
|
|||||||
import { CronModule } from "src/utils/cron/cron.module";
|
import { CronModule } from "src/utils/cron/cron.module";
|
||||||
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.module";
|
||||||
import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
||||||
|
import { HashModule } from "src/utils/hash/hash.module";
|
||||||
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
||||||
|
import { AuthModule } from "src/auth/auth.module";
|
||||||
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
|
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
|
||||||
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
|
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.db.service";
|
||||||
import { UserSignDbService } from "./entities/db-service/sign.db.service";
|
import { UserSignDbService } from "./entities/db-service/sign.db.service";
|
||||||
@@ -35,6 +37,8 @@ import { RequestManagementService } from "./request-management.service";
|
|||||||
import { RequestManagementController } from "./request-management.controller";
|
import { RequestManagementController } from "./request-management.controller";
|
||||||
import { RequestManagementV2Controller } from "./request-management.v2.controller";
|
import { RequestManagementV2Controller } from "./request-management.v2.controller";
|
||||||
import { ExpertInitiatedController } from "./expert-initiated.controller";
|
import { ExpertInitiatedController } from "./expert-initiated.controller";
|
||||||
|
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
|
||||||
|
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -43,6 +47,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
|
|||||||
SandHubModule,
|
SandHubModule,
|
||||||
SmsManagerModule,
|
SmsManagerModule,
|
||||||
PublicIdModule,
|
PublicIdModule,
|
||||||
|
HashModule,
|
||||||
WorkflowStepManagementModule,
|
WorkflowStepManagementModule,
|
||||||
PlatesModule,
|
PlatesModule,
|
||||||
MulterModule.register({
|
MulterModule.register({
|
||||||
@@ -58,11 +63,14 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
|
|||||||
]),
|
]),
|
||||||
forwardRef(() => ClaimRequestManagementModule),
|
forwardRef(() => ClaimRequestManagementModule),
|
||||||
CronModule,
|
CronModule,
|
||||||
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
RequestManagementController,
|
RequestManagementController,
|
||||||
RequestManagementV2Controller,
|
RequestManagementV2Controller,
|
||||||
ExpertInitiatedController,
|
ExpertInitiatedController,
|
||||||
|
ExpertInitiatedV2Controller,
|
||||||
|
RegistrarInitiatedController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
RequestManagementService,
|
RequestManagementService,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ import {
|
|||||||
CreateBlameRequestDtoV2,
|
CreateBlameRequestDtoV2,
|
||||||
DescriptionDto,
|
DescriptionDto,
|
||||||
LocationDto,
|
LocationDto,
|
||||||
|
CarBodyFormDto,
|
||||||
} from "./dto/create-request-management.dto";
|
} from "./dto/create-request-management.dto";
|
||||||
import { RequestManagementService } from "./request-management.service";
|
import { RequestManagementService } from "./request-management.service";
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ import { RequestManagementService } from "./request-management.service";
|
|||||||
export class RequestManagementV2Controller {
|
export class RequestManagementV2Controller {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly requestManagementService: RequestManagementService,
|
private readonly requestManagementService: RequestManagementService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
@@ -64,6 +65,27 @@ export class RequestManagementV2Controller {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||||
|
async getAllBlameRequestsV2(
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getAllBlameRequestsV2(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get one blame request by id. Allowed for the request owner (party) or the initiating field expert.
|
||||||
|
*/
|
||||||
|
@Get(":requestId")
|
||||||
|
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||||
|
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
|
||||||
|
async getBlameRequestV2(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.getBlameRequestV2(requestId, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("/blame-confession/:requestId")
|
@Post("/blame-confession/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: BlameConfessionDtoV2 })
|
@ApiBody({ type: BlameConfessionDtoV2 })
|
||||||
@@ -76,6 +98,19 @@ export class RequestManagementV2Controller {
|
|||||||
return this.requestManagementService.blameConfessionV2(requestId, body, user);
|
return this.requestManagementService.blameConfessionV2(requestId, body, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CAR_BODY only: submit accident type (car vs object). Call this when nextStep is CAR_BODY_ACCIDENT_TYPE. */
|
||||||
|
@Post("/car-body-form/:requestId")
|
||||||
|
@ApiParam({ name: "requestId" })
|
||||||
|
@ApiBody({ type: CarBodyFormDto })
|
||||||
|
@UseGuards(GlobalGuard)
|
||||||
|
async carBodyFormV2(
|
||||||
|
@Param("requestId") requestId: string,
|
||||||
|
@Body() body: CarBodyFormDto,
|
||||||
|
@CurrentUser() user,
|
||||||
|
) {
|
||||||
|
return this.requestManagementService.carBodyAccidentTypeFormV2(requestId, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -185,7 +220,28 @@ export class RequestManagementV2Controller {
|
|||||||
|
|
||||||
@Post("/add-detail-description/:requestId")
|
@Post("/add-detail-description/:requestId")
|
||||||
@ApiParam({ name: "requestId" })
|
@ApiParam({ name: "requestId" })
|
||||||
@ApiBody({ type: DescriptionDto })
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
"THIRD_PARTY: send only desc. CAR_BODY: send desc + accidentDate, accidentTime, weatherCondition, roadCondition, lightCondition (all required).",
|
||||||
|
type: DescriptionDto,
|
||||||
|
examples: {
|
||||||
|
thirdParty: {
|
||||||
|
summary: "THIRD_PARTY (only desc)",
|
||||||
|
value: { desc: "Accident description text" },
|
||||||
|
},
|
||||||
|
carBody: {
|
||||||
|
summary: "CAR_BODY (desc + conditions)",
|
||||||
|
value: {
|
||||||
|
desc: "Accident description",
|
||||||
|
accidentDate: "2025-12-08",
|
||||||
|
accidentTime: "14:30",
|
||||||
|
weatherCondition: "صاف",
|
||||||
|
roadCondition: "خشک",
|
||||||
|
lightCondition: "روز",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@UseGuards(GlobalGuard)
|
@UseGuards(GlobalGuard)
|
||||||
async addDescriptionV2(
|
async addDescriptionV2(
|
||||||
@Param("requestId") requestId: string,
|
@Param("requestId") requestId: string,
|
||||||
@@ -276,10 +332,10 @@ export class RequestManagementV2Controller {
|
|||||||
@UploadedFile() sign: Express.Multer.File,
|
@UploadedFile() sign: Express.Multer.File,
|
||||||
) {
|
) {
|
||||||
// Convert string "true"/"false" to boolean
|
// Convert string "true"/"false" to boolean
|
||||||
const acceptDecision = typeof isAccept === "string"
|
const acceptDecision = typeof isAccept === "string"
|
||||||
? isAccept === "true"
|
? isAccept === "true"
|
||||||
: Boolean(isAccept);
|
: Boolean(isAccept);
|
||||||
|
|
||||||
return this.requestManagementService.userSignDecisionV2(
|
return this.requestManagementService.userSignDecisionV2(
|
||||||
requestId,
|
requestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
@@ -331,6 +387,11 @@ export class RequestManagementV2Controller {
|
|||||||
format: "binary",
|
format: "binary",
|
||||||
description: "Video file (if requested)",
|
description: "Video file (if requested)",
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Text description when expert requested ResendItemType.description",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -371,11 +432,13 @@ export class RequestManagementV2Controller {
|
|||||||
voice?: Express.Multer.File[];
|
voice?: Express.Multer.File[];
|
||||||
video?: Express.Multer.File[];
|
video?: Express.Multer.File[];
|
||||||
},
|
},
|
||||||
|
@Body("description") description?: string,
|
||||||
) {
|
) {
|
||||||
return this.requestManagementService.uploadResendDocumentsV2(
|
return this.requestManagementService.uploadResendDocumentsV2(
|
||||||
requestId,
|
requestId,
|
||||||
user.sub,
|
user.sub,
|
||||||
files,
|
files,
|
||||||
|
description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,144 @@ export class SandHubService {
|
|||||||
private readonly sandHubDbService: SandHubDbService,
|
private readonly sandHubDbService: SandHubDbService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When false (default), no outbound HTTP to SandHub/Tejarat gateways — all inquiries return mocks.
|
||||||
|
* Set `SANDHUB_USE_REAL_API=true` to restore live calls.
|
||||||
|
*/
|
||||||
|
private useLiveSandHubApis(): boolean {
|
||||||
|
return process.env.SANDHUB_USE_REAL_API === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed plate/insurance inquiry payload used everywhere we mock block-inquiry style APIs. */
|
||||||
|
private getDefaultMockPlateInquiryRaw(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
PrntPlcyCmpDocNo: "1403/1143-70591/200/35",
|
||||||
|
MapTypNam: "پرايد هاچ بک -111",
|
||||||
|
MtrNum: "5215907",
|
||||||
|
ShsNum: "NAS431100E5798656",
|
||||||
|
DisFnYrNum: "0",
|
||||||
|
DisLfYrNum: "0",
|
||||||
|
DisPrsnYrNum: "0",
|
||||||
|
DisPrsnYrPrcnt: "0",
|
||||||
|
DisFnYrPrcnt: "0",
|
||||||
|
DisLfYrPrcnt: "0",
|
||||||
|
vin: "IRPC941V2BD798656",
|
||||||
|
MapVehicleSystemName: "ثبت نشده",
|
||||||
|
LfCvrCptl: 0,
|
||||||
|
FnCvrCptl: 0,
|
||||||
|
PrsnCvrCptl: 0,
|
||||||
|
VehicleSystemCode: 1,
|
||||||
|
EdrsJson: '[{"id":1,"Dsc":" الحاقيه توضيحات ندارد"}]',
|
||||||
|
PersonCvrCptl: 12000000000,
|
||||||
|
LifeCvrCptl: 16000000000,
|
||||||
|
FinancialCvrCptl: 4000000000,
|
||||||
|
CarGroupCode: 3,
|
||||||
|
CylCnt: 4,
|
||||||
|
LastCompanyDocumentNumber: "03/1031/2835/1001/662",
|
||||||
|
UsageCode: "8",
|
||||||
|
MapUsageCode: 1,
|
||||||
|
MapUsageName: "شخصي",
|
||||||
|
Plk1: 59,
|
||||||
|
Plk2: 16,
|
||||||
|
Plk3: 419,
|
||||||
|
PlkSrl: 78,
|
||||||
|
PrintEndorsCompanyDocumentNumber: "بيمه نامه الحاقيه ندارد.",
|
||||||
|
EndorseDate: null,
|
||||||
|
InsuranceFullName: null,
|
||||||
|
SystemField: "سايپا",
|
||||||
|
TypeField: "111SE",
|
||||||
|
UsageField: "سواري",
|
||||||
|
MainColorField: "سفيد",
|
||||||
|
SecondColorField: "سفيد شيري",
|
||||||
|
ModelField: "1394",
|
||||||
|
CapacityField: "جمعا 4 نفر",
|
||||||
|
CylinderNumberField: "4",
|
||||||
|
EngineNumberField: "5215907",
|
||||||
|
ChassisNumberField: "NAS431100E5798656",
|
||||||
|
VinNumberField: "IRPC941V2BD798656",
|
||||||
|
InstallDateField: "1403/03/01",
|
||||||
|
AxelNumberField: "2",
|
||||||
|
WheelNumberField: "4",
|
||||||
|
CompanyName: "بیمه سامان",
|
||||||
|
CompanyCode: "15",
|
||||||
|
IssueDate: "1403/04/06",
|
||||||
|
SatrtDate: "1403/04/06",
|
||||||
|
EndDate: "1404/04/06",
|
||||||
|
Thrname: "",
|
||||||
|
EndorseText: null,
|
||||||
|
PolicyHealthLossCount: 0,
|
||||||
|
PolicyFinancialLossCount: 0,
|
||||||
|
PolicyPersonLossCount: 0,
|
||||||
|
Tonage: 0,
|
||||||
|
ThirdPolicyCode: 10502330579,
|
||||||
|
DiscountPersonPercent: null,
|
||||||
|
DiscountThirdPercent: null,
|
||||||
|
SystemCodeCii: 0,
|
||||||
|
SystemNameCii: "ثبت نشده",
|
||||||
|
TypeCodeCii: 12189,
|
||||||
|
TypeNameCii: "پرايد هاچ بک -111",
|
||||||
|
UsageNameCii: "سواري",
|
||||||
|
UsageCodeCii: 1,
|
||||||
|
ModelCii: 1394,
|
||||||
|
damageTypes: [],
|
||||||
|
StatusTypeCode: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bodies returned from `makeSandHubRequest` in mock mode (same shape callers expect from real API).
|
||||||
|
*/
|
||||||
|
private buildMockSandHubResponseForUrl(url: string): any {
|
||||||
|
const u = (url || "").toLowerCase();
|
||||||
|
if (u.includes("personal-inquiry")) {
|
||||||
|
return {
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
firstName: "نام",
|
||||||
|
lastName: "خانوادگی",
|
||||||
|
fatherName: "-",
|
||||||
|
birthCertificateNumber: "-",
|
||||||
|
nin: "-",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (u.includes("driver-license-check")) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
IsSucceed: true,
|
||||||
|
Message: "mock",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (u.includes("ownership")) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
IsSuccess: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (u.includes("sheba")) {
|
||||||
|
return {
|
||||||
|
ReturnValue: true,
|
||||||
|
HasError: false,
|
||||||
|
Message: "mock-sheba-ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (u.includes("block-inquiry") || u.includes("tejarat")) {
|
||||||
|
return this.mapNewApiResponseToOldFormat(
|
||||||
|
this.getDefaultMockPlateInquiryRaw(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
`[MOCK] Unrecognized SandHub URL; returning generic OK: ${url}`,
|
||||||
|
);
|
||||||
|
return { data: {}, message: "mock-ok" };
|
||||||
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(): Promise<string> {
|
||||||
|
if (!this.useLiveSandHubApis()) {
|
||||||
|
return "mock-sandhub-access-token";
|
||||||
|
}
|
||||||
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
|
||||||
return this.loginToken;
|
return this.loginToken;
|
||||||
}
|
}
|
||||||
@@ -67,6 +204,9 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getTejaratAccessToken(): Promise<string> {
|
private async getTejaratAccessToken(): Promise<string> {
|
||||||
|
if (!this.useLiveSandHubApis()) {
|
||||||
|
return "mock-tejarat-access-token";
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
this.tejaratAccessToken &&
|
this.tejaratAccessToken &&
|
||||||
this.tejaratTokenExpiry &&
|
this.tejaratTokenExpiry &&
|
||||||
@@ -122,6 +262,10 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
private async makeTejaratRequest(url: string, payload: any, maxRetries = 2) {
|
||||||
|
if (!this.useLiveSandHubApis()) {
|
||||||
|
this.logger.log(`[MOCK] Tejarat POST skipped: ${url}`);
|
||||||
|
return this.getDefaultMockPlateInquiryRaw();
|
||||||
|
}
|
||||||
const INITIAL_DELAY = 500;
|
const INITIAL_DELAY = 500;
|
||||||
const BACKOFF_FACTOR = 2;
|
const BACKOFF_FACTOR = 2;
|
||||||
|
|
||||||
@@ -183,7 +327,14 @@ export class SandHubService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
|
||||||
const raw = await this.makeTejaratRequest(requestUrl, requestPayload);
|
const raw = this.useLiveSandHubApis()
|
||||||
|
? await this.makeTejaratRequest(requestUrl, requestPayload)
|
||||||
|
: this.getDefaultMockPlateInquiryRaw();
|
||||||
|
if (!this.useLiveSandHubApis()) {
|
||||||
|
this.logger.debug(
|
||||||
|
`[MOCK] getTejaratBlockInquiry plate=${JSON.stringify(requestPayload)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const mapped = this.mapNewApiResponseToOldFormat(raw);
|
const mapped = this.mapNewApiResponseToOldFormat(raw);
|
||||||
return { raw, mapped };
|
return { raw, mapped };
|
||||||
}
|
}
|
||||||
@@ -193,6 +344,10 @@ export class SandHubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
|
private async makeSandHubRequest(url: string, payload: any, maxRetries = 3) {
|
||||||
|
if (!this.useLiveSandHubApis()) {
|
||||||
|
this.logger.log(`[MOCK] SandHub POST skipped: ${url}`);
|
||||||
|
return this.buildMockSandHubResponseForUrl(url);
|
||||||
|
}
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const INITIAL_DELAY = 1000;
|
const INITIAL_DELAY = 1000;
|
||||||
const BACKOFF_FACTOR = 2;
|
const BACKOFF_FACTOR = 2;
|
||||||
@@ -293,16 +448,25 @@ export class SandHubService {
|
|||||||
rightTwoDigits: String(userDetail.plate.ir),
|
rightTwoDigits: String(userDetail.plate.ir),
|
||||||
nationalCode: userDetail.nationalCodeOfInsurer,
|
nationalCode: userDetail.nationalCodeOfInsurer,
|
||||||
};
|
};
|
||||||
const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`;
|
const base = process.env.SANDHUB_BASE_URL ?? "";
|
||||||
const response = await this.makeSandHubRequest(requestUrl, requestPayload);
|
const requestUrl = `${base}/block-inquiry-tejarat`;
|
||||||
|
|
||||||
// Map the new API response format to the old format
|
|
||||||
let result = this.mapNewApiResponseToOldFormat(response);
|
|
||||||
|
|
||||||
if (result.usgCod !== "8") {
|
let response: any;
|
||||||
throw new Error("خودرو شما شخصی / سواری نمی باشد")
|
if (this.useLiveSandHubApis()) {
|
||||||
|
response = await this.makeSandHubRequest(requestUrl, requestPayload);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(
|
||||||
|
`[MOCK] getSandHubResponse plate=${JSON.stringify(requestPayload)}`,
|
||||||
|
);
|
||||||
|
response = this.getDefaultMockPlateInquiryRaw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = this.mapNewApiResponseToOldFormat(response);
|
||||||
|
|
||||||
|
// if (result.usgCod !== "8") {
|
||||||
|
// throw new Error("خودرو شما شخصی / سواری نمی باشد")
|
||||||
|
// }
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(err);
|
throw new Error(err);
|
||||||
|
|||||||
40
src/users/entities/db-service/registrar.db.service.ts
Normal file
40
src/users/entities/db-service/registrar.db.service.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectModel } from "@nestjs/mongoose";
|
||||||
|
import { FilterQuery, Model, Types } from "mongoose";
|
||||||
|
import { RegistrarModel } from "../schema/registrar.schema";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RegistrarDbService {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(RegistrarModel.name)
|
||||||
|
private readonly registrarModel: Model<RegistrarModel>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
user: Partial<RegistrarModel> & { password: string },
|
||||||
|
): Promise<RegistrarModel> {
|
||||||
|
return this.registrarModel.create(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(
|
||||||
|
filter: FilterQuery<RegistrarModel>,
|
||||||
|
): Promise<RegistrarModel | null> {
|
||||||
|
return this.registrarModel.findOne(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneAndUpdate(
|
||||||
|
filter: FilterQuery<RegistrarModel>,
|
||||||
|
update: any,
|
||||||
|
): Promise<RegistrarModel | null> {
|
||||||
|
return this.registrarModel.findOneAndUpdate(filter, update, { new: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOne(filter: FilterQuery<RegistrarModel>, update: any) {
|
||||||
|
return this.registrarModel.updateOne(filter, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(userId: string): Promise<RegistrarModel | null> {
|
||||||
|
return this.registrarModel.findOne({ _id: new Types.ObjectId(userId) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
src/users/entities/schema/registrar.schema.ts
Normal file
38
src/users/entities/schema/registrar.schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||||
|
|
||||||
|
@Schema({
|
||||||
|
collection: "registrar",
|
||||||
|
versionKey: false,
|
||||||
|
timestamps: true,
|
||||||
|
})
|
||||||
|
export class RegistrarModel {
|
||||||
|
_id?: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ type: "string", unique: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Prop({ type: "string" })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, required: true, index: true })
|
||||||
|
clientKey: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({ default: RoleEnum.REGISTRAR })
|
||||||
|
role: RoleEnum;
|
||||||
|
|
||||||
|
@Prop({ type: "string", default: "" })
|
||||||
|
otp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RegistrarDbSchema = SchemaFactory.createForClass(RegistrarModel);
|
||||||
|
|
||||||
|
RegistrarDbSchema.pre("save", function (next) {
|
||||||
|
if (!this.username) this.username = this.email;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { OtpModule } from "src/utils/otp/otp.module";
|
|||||||
import { ExpertDbService } from "./entities/db-service/expert.db.service";
|
import { ExpertDbService } from "./entities/db-service/expert.db.service";
|
||||||
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
|
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
|
||||||
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
|
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
|
||||||
|
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
|
||||||
import { UserDbService } from "./entities/db-service/user.db.service";
|
import { UserDbService } from "./entities/db-service/user.db.service";
|
||||||
import {
|
import {
|
||||||
DamageExpertDbSchema,
|
DamageExpertDbSchema,
|
||||||
@@ -21,6 +22,10 @@ import {
|
|||||||
InsurerExpertDbSchema,
|
InsurerExpertDbSchema,
|
||||||
InsurerExpertModel,
|
InsurerExpertModel,
|
||||||
} from "./entities/schema/insurer-expert.schema";
|
} from "./entities/schema/insurer-expert.schema";
|
||||||
|
import {
|
||||||
|
RegistrarDbSchema,
|
||||||
|
RegistrarModel,
|
||||||
|
} from "./entities/schema/registrar.schema";
|
||||||
import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -31,6 +36,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
{ name: ExpertModel.name, schema: ExpertDbSchema },
|
{ name: ExpertModel.name, schema: ExpertDbSchema },
|
||||||
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
|
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
|
||||||
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
|
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
|
||||||
|
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
|
||||||
]),
|
]),
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HashModule,
|
HashModule,
|
||||||
@@ -42,6 +48,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
DamageExpertDbService,
|
DamageExpertDbService,
|
||||||
FieldExpertDbService,
|
FieldExpertDbService,
|
||||||
InsurerExpertDbService,
|
InsurerExpertDbService,
|
||||||
|
RegistrarDbService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
UserDbService,
|
UserDbService,
|
||||||
@@ -49,6 +56,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
|
|||||||
DamageExpertDbService,
|
DamageExpertDbService,
|
||||||
FieldExpertDbService,
|
FieldExpertDbService,
|
||||||
InsurerExpertDbService,
|
InsurerExpertDbService,
|
||||||
|
RegistrarDbService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
78
src/utils/sms-manager/kavenegar-response.ts
Normal file
78
src/utils/sms-manager/kavenegar-response.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const MAX_RAW_LEN = 8000;
|
||||||
|
|
||||||
|
export interface KavenegarNormalized {
|
||||||
|
httpLikeStatus?: number;
|
||||||
|
message?: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeJsonStringify(value: unknown): string {
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(value);
|
||||||
|
return s.length > MAX_RAW_LEN
|
||||||
|
? `${s.slice(0, MAX_RAW_LEN)}...[truncated]`
|
||||||
|
: s;
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kavenegar REST responses use `{ return: { status, message }, ... }`.
|
||||||
|
* Transport failures may yield non-JSON strings or malformed bodies.
|
||||||
|
*/
|
||||||
|
export function normalizeKavenegarBody(body: unknown): KavenegarNormalized {
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
return normalizeKavenegarBody(JSON.parse(body) as unknown);
|
||||||
|
} catch {
|
||||||
|
const raw =
|
||||||
|
body.length > MAX_RAW_LEN
|
||||||
|
? `${body.slice(0, MAX_RAW_LEN)}...[truncated]`
|
||||||
|
: body;
|
||||||
|
return { raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpLikeStatus: number | undefined;
|
||||||
|
let message: string | undefined;
|
||||||
|
|
||||||
|
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||||
|
const ret = (body as { return?: { status?: unknown; message?: unknown } })
|
||||||
|
.return;
|
||||||
|
if (ret && typeof ret === "object") {
|
||||||
|
if (typeof ret.status === "number" && Number.isFinite(ret.status)) {
|
||||||
|
httpLikeStatus = ret.status;
|
||||||
|
}
|
||||||
|
if (typeof ret.message === "string") {
|
||||||
|
message = ret.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpLikeStatus,
|
||||||
|
message,
|
||||||
|
raw: safeJsonStringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kavenegar uses `return.status === 200` for a successful API call. */
|
||||||
|
export function isKavenegarSuccess(body: unknown): boolean {
|
||||||
|
return normalizeKavenegarBody(body).httpLikeStatus === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nest client sometimes rejects with a JSON string `{ "error": "..." }`. */
|
||||||
|
export function unwrapKavenegarTransportError(err: unknown): unknown {
|
||||||
|
if (typeof err === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(err) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { transportMessage: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return { name: err.name, message: err.message };
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
import { KavenegarService } from "@fraybabak/kavenegar_nest";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
isKavenegarSuccess,
|
||||||
|
KavenegarNormalized,
|
||||||
|
normalizeKavenegarBody,
|
||||||
|
safeJsonStringify,
|
||||||
|
unwrapKavenegarTransportError,
|
||||||
|
} from "./kavenegar-response";
|
||||||
|
import {
|
||||||
|
SmsProviderException,
|
||||||
|
SmsTransportException,
|
||||||
|
} from "./sms-provider.exception";
|
||||||
|
|
||||||
export interface SendMessage {
|
export interface SendMessage {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -15,16 +26,84 @@ export interface VerifyLookUpMessage {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsManagerService {
|
export class SmsManagerService {
|
||||||
|
private readonly logger = new Logger(SmsManagerService.name);
|
||||||
|
|
||||||
constructor(private readonly sender: KavenegarService) {}
|
constructor(private readonly sender: KavenegarService) {}
|
||||||
|
|
||||||
async sendMessage(data: SendMessage) {
|
async sendMessage(data: SendMessage) {
|
||||||
return await this.sender.Send({
|
try {
|
||||||
sender: "10008663",
|
const body = await this.sender.Send({
|
||||||
...data,
|
sender: "10008663",
|
||||||
});
|
...data,
|
||||||
|
});
|
||||||
|
if (!isKavenegarSuccess(body)) {
|
||||||
|
const normalized = normalizeKavenegarBody(body);
|
||||||
|
this.logProviderRejection("send", data.receptor, undefined, normalized);
|
||||||
|
throw new SmsProviderException("send", {
|
||||||
|
receptor: data.receptor,
|
||||||
|
providerBody: body,
|
||||||
|
normalized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Kavenegar Send ok receptor=${data.receptor} status=200`,
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SmsProviderException) throw e;
|
||||||
|
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
|
||||||
|
this.logger.error(
|
||||||
|
`Kavenegar Send transport error receptor=${data.receptor} detail=${detail}`,
|
||||||
|
);
|
||||||
|
throw new SmsTransportException("send", { receptor: data.receptor }, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyLookUp(data: VerifyLookUpMessage) {
|
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||||
return await this.sender.verifyLookup(data);
|
try {
|
||||||
|
const body = await this.sender.verifyLookup(data);
|
||||||
|
if (!isKavenegarSuccess(body)) {
|
||||||
|
const normalized = normalizeKavenegarBody(body);
|
||||||
|
this.logProviderRejection(
|
||||||
|
"verifyLookUp",
|
||||||
|
data.receptor,
|
||||||
|
data.template,
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
throw new SmsProviderException("verifyLookup", {
|
||||||
|
receptor: data.receptor,
|
||||||
|
template: data.template,
|
||||||
|
providerBody: body,
|
||||||
|
normalized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Kavenegar verifyLookUp ok receptor=${data.receptor} template=${data.template} status=200`,
|
||||||
|
);
|
||||||
|
return body;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SmsProviderException) throw e;
|
||||||
|
const detail = safeJsonStringify(unwrapKavenegarTransportError(e));
|
||||||
|
this.logger.error(
|
||||||
|
`Kavenegar verifyLookUp transport error receptor=${data.receptor} template=${data.template} detail=${detail}`,
|
||||||
|
);
|
||||||
|
throw new SmsTransportException(
|
||||||
|
"verifyLookup",
|
||||||
|
{ receptor: data.receptor, template: data.template },
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logProviderRejection(
|
||||||
|
op: "send" | "verifyLookUp",
|
||||||
|
receptor: string,
|
||||||
|
template: string | undefined,
|
||||||
|
normalized: KavenegarNormalized,
|
||||||
|
) {
|
||||||
|
const t = template != null && template !== "" ? ` template=${template}` : "";
|
||||||
|
this.logger.warn(
|
||||||
|
`Kavenegar ${op} rejected receptor=${receptor}${t} providerStatus=${normalized.httpLikeStatus ?? "unknown"} providerMessage=${normalized.message ?? "n/a"} body=${normalized.raw}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/utils/sms-manager/sms-provider.exception.ts
Normal file
69
src/utils/sms-manager/sms-provider.exception.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
KavenegarNormalized,
|
||||||
|
safeJsonStringify,
|
||||||
|
unwrapKavenegarTransportError,
|
||||||
|
} from "./kavenegar-response";
|
||||||
|
|
||||||
|
export type SmsOperation = "verifyLookup" | "send";
|
||||||
|
|
||||||
|
export class SmsProviderException extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly operation: SmsOperation,
|
||||||
|
public readonly meta: {
|
||||||
|
receptor: string;
|
||||||
|
template?: string;
|
||||||
|
providerBody: unknown;
|
||||||
|
normalized: KavenegarNormalized;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
"SMS delivery could not be confirmed by the provider.",
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SmsTransportException extends HttpException {
|
||||||
|
constructor(
|
||||||
|
public readonly operation: SmsOperation,
|
||||||
|
public readonly meta: { receptor: string; template?: string },
|
||||||
|
public readonly causeUnknown: unknown,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
"SMS service failed before a provider response was received.",
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-line detail for logs (provider vs transport vs unexpected). */
|
||||||
|
export function describeSmsError(err: unknown): string {
|
||||||
|
if (err instanceof SmsProviderException) {
|
||||||
|
const n = err.meta.normalized;
|
||||||
|
return [
|
||||||
|
`SmsProviderException(${err.operation})`,
|
||||||
|
`receptor=${err.meta.receptor}`,
|
||||||
|
err.meta.template ? `template=${err.meta.template}` : "",
|
||||||
|
`providerStatus=${n.httpLikeStatus ?? "unknown"}`,
|
||||||
|
`providerMessage=${n.message ?? "n/a"}`,
|
||||||
|
`body=${n.raw}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
if (err instanceof SmsTransportException) {
|
||||||
|
return [
|
||||||
|
`SmsTransportException(${err.operation})`,
|
||||||
|
`receptor=${err.meta.receptor}`,
|
||||||
|
err.meta.template ? `template=${err.meta.template}` : "",
|
||||||
|
`detail=${safeJsonStringify(unwrapKavenegarTransportError(err.causeUnknown))}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return `${err.name}: ${err.message}`;
|
||||||
|
}
|
||||||
|
return safeJsonStringify(err);
|
||||||
|
}
|
||||||
@@ -128,9 +128,9 @@ export class CreateWorkflowStepDto {
|
|||||||
stepKey: WorkflowStep | ClaimWorkflowStep;
|
stepKey: WorkflowStep | ClaimWorkflowStep;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Workflow type: THIRD_PARTY (blame) or CLAIM (claim workflow)',
|
description: 'Workflow type: THIRD_PARTY (blame), CAR_BODY (blame), or CLAIM (claim workflow)',
|
||||||
example: 'THIRD_PARTY',
|
example: 'THIRD_PARTY',
|
||||||
enum: ['THIRD_PARTY', 'CLAIM']
|
enum: ['THIRD_PARTY', 'CAR_BODY', 'CLAIM']
|
||||||
})
|
})
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1539,3 +1539,24 @@ export const claimWorkflowStepExamples = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blame V2 expert resend: same `stepKey`s as the initial THIRD_PARTY blame flow
|
||||||
|
* (`FIRST_VOICE` → `FIRST_DESCRIPTION` → `SECOND_VOICE` → `SECOND_DESCRIPTION`).
|
||||||
|
* Seed workflow steps once via POST bodies below (or reuse existing rows). Resend only stores
|
||||||
|
* ordered keys on `blameCase.expert.resend` — clients resolve full field defs from `workflowSteps`.
|
||||||
|
*/
|
||||||
|
export const blameResendWorkflowStepExamples = {
|
||||||
|
firstVoice: createWorkflowStepExamples.example6,
|
||||||
|
firstDescription: createWorkflowStepExamples.example7,
|
||||||
|
secondVoice: createWorkflowStepExamples.example11,
|
||||||
|
secondDescription: createWorkflowStepExamples.example12,
|
||||||
|
stepKeysInOrder: [
|
||||||
|
'FIRST_VOICE',
|
||||||
|
'FIRST_DESCRIPTION',
|
||||||
|
'SECOND_VOICE',
|
||||||
|
'SECOND_DESCRIPTION',
|
||||||
|
] as const,
|
||||||
|
note:
|
||||||
|
'Override per party with PartyResendRequestDto.requiredWorkflowStepKeys or ResendItemType.VOICE + DESCRIPTION when calling expert resend V2.',
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1123,6 +1123,43 @@ export class WorkflowStepManagementService {
|
|||||||
party: 'second'
|
party: 'second'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// CAR_BODY workflow: accident type form (replaces confession for CAR_BODY)
|
||||||
|
{
|
||||||
|
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
|
||||||
|
type: 'CAR_BODY',
|
||||||
|
stepNumber: 2,
|
||||||
|
isActive: true,
|
||||||
|
stepName_fa: 'نوع حادثه بدنه',
|
||||||
|
stepName_en: 'Car Body Accident Type',
|
||||||
|
description_fa: 'انتخاب اینکه حادثه با خودرو بوده یا با شیء',
|
||||||
|
description_en: 'Select whether accident was with another car or with an object',
|
||||||
|
category: 'FIRST_PARTY',
|
||||||
|
requiredPreviousSteps: [WorkflowStep.CREATED],
|
||||||
|
nextPossibleSteps: [WorkflowStep.FIRST_VIDEO],
|
||||||
|
allowedRoles: ['user'],
|
||||||
|
estimatedDuration: 1,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: '507f1f77bcf86cd7994390cb' as any,
|
||||||
|
name_fa: 'نوع حادثه',
|
||||||
|
name_en: 'accidentType',
|
||||||
|
label_fa: 'نوع حادثه',
|
||||||
|
label_en: 'Accident Type',
|
||||||
|
placeholder_fa: 'حادثه با خودرو یا شیء؟',
|
||||||
|
placeholder_en: 'Accident with car or object?',
|
||||||
|
value: [
|
||||||
|
{ label_fa: 'با خودرو', label_en: 'With Car', value: 'car' },
|
||||||
|
{ label_fa: 'با شیء', label_en: 'With Object', value: 'object' }
|
||||||
|
],
|
||||||
|
dataType: 'select',
|
||||||
|
required: true,
|
||||||
|
order: 1,
|
||||||
|
isActive: true,
|
||||||
|
validation: { enum: ['car', 'object'] }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
metadata: { icon: 'car', color: '#10B981' }
|
||||||
|
},
|
||||||
// Add more default steps as needed...
|
// Add more default steps as needed...
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user