Compare commits

...

55 Commits

Author SHA1 Message Date
af1a07743e merge upstream 2026-04-19 17:23:47 +03:30
fca88bc151 update the resend request and user side 2026-04-19 17:23:14 +03:30
277f2d4d66 Merge pull request 'Fixed lock mechanism for damage-expert' (#26) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#26
2026-04-19 12:32:22 +03:30
SepehrYahyaee
514018beb1 Fixed lock mechanism for damage-expert 2026-04-19 12:32:03 +03:30
374b15933d Merge pull request 'add blame in claim details plus auto expert decision bug fixed' (#25) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#25
2026-04-19 11:59:33 +03:30
08a4d754c1 add blame in claim details plus auto expert decision bug fixed 2026-04-19 11:57:18 +03:30
c5b2d7b520 Merge pull request 'Fixed steps' (#24) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#24
2026-04-19 10:42:05 +03:30
SepehrYahyaee
5a84080bf1 Fixed steps 2026-04-19 10:41:51 +03:30
68b3b2000b Merge pull request 'Fixed statuses and switched some' (#23) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#23
2026-04-18 17:42:27 +03:30
SepehrYahyaee
028ca899fe resolved conflicts and maintained new features 2026-04-18 17:41:45 +03:30
SepehrYahyaee
0086b8db4d fixed many bugs and step issues 2026-04-18 17:04:19 +03:30
SepehrYahyaee
7f5b64f2a6 Fixed DTO 2026-04-18 14:02:50 +03:30
2898897dd9 Merge pull request 'lookups api is offline' (#22) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#22
2026-04-18 13:29:11 +03:30
e8a3873851 lookups api is offline 2026-04-18 13:26:37 +03:30
131df63a3f Merge pull request 'main' (#21) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#21
2026-04-18 12:35:41 +03:30
b9eb0ab5bd Merge remote-tracking branch 'upstream/main' | Merge upstream into main and reapply local changes 2026-04-18 12:30:32 +03:30
4bdb9fd469 update some important issues 2026-04-18 10:49:22 +03:30
38f700c3d7 Merge pull request 'Fixed bugs of car-body steps' (#19) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#19
2026-04-18 10:10:58 +03:30
SepehrYahyaee
1a1d55bc2e Fixed bugs of car-body steps 2026-04-18 10:10:19 +03:30
a2f1d5ea7f Merge pull request 'YARA-725' (#18) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#18
2026-04-15 12:32:53 +03:30
SepehrYahyaee
9a65071276 YARA-725 2026-04-15 12:32:25 +03:30
12d6fa4d73 Merge pull request 'YARA-784, YARA-789, YARA-791' (#17) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#17
2026-04-15 11:58:36 +03:30
SepehrYahyaee
640b240ada YARA-791 2026-04-15 11:57:09 +03:30
SepehrYahyaee
f8fbbb7ac6 YARA-784 2026-04-15 11:43:19 +03:30
SepehrYahyaee
0e2789c209 YARA-789 2026-04-15 10:16:18 +03:30
7661862564 Merge pull request 'Fixed reports+insurer + fixed field_expert not accessing claim APIs temporarily' (#16) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#16
2026-04-13 14:10:39 +03:30
SepehrYahyaee
a2143fe1c5 Fixed field_expert not accessing claim apis 2026-04-13 14:09:29 +03:30
Soheil Hajizadeh
494e3d93ab update the expert claim dto 2026-04-11 09:46:54 +03:30
9afb079786 Rework the reports and insurer part 2026-04-11 00:12:10 +03:30
25958bb966 merge upstream 2026-04-08 16:09:14 +03:30
Soheil Hajizadeh
1d51370b3e vehicle data added to get single request by expert , also submit in person 2026-04-08 16:08:31 +03:30
e44721b7be Merge pull request 'YARA-743, YARA-763, YARA-764' (#15) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#15
2026-04-06 15:00:02 +03:30
7ef057dc1f YARA-764 2026-04-06 14:33:33 +03:30
1b68a81204 YARA-763 2026-04-06 14:15:31 +03:30
f77da50d1f YARA-743 2026-04-06 14:01:45 +03:30
79c2982e19 Merge pull request 'Fixed log for error' (#14) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#14
2026-04-05 16:45:12 +03:30
fb4d166c31 Fixed log for error 2026-04-05 16:44:14 +03:30
9084f8fafb Merge pull request 'update the user side from the list of the user requests' (#13) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#13
2026-04-04 14:03:18 +03:30
b216a363fb merge upstream 2026-03-29 12:22:53 +03:30
Soheil Hajizadeh
b2391751f0 update the user side for the requests list 2026-03-29 12:21:01 +03:30
df6d2bd04d Merge pull request 'Mocked all external API calls' (#12) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#12
2026-03-29 11:48:37 +03:30
f277d87dfa Mocked all external API calls 2026-03-29 11:46:56 +03:30
672ec25438 Merge pull request 'Fixed signature error for car-body' (#11) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#11
2026-03-29 10:43:33 +03:30
aa871c86f6 Fixed signature error for car-body 2026-03-29 10:41:38 +03:30
553a34054c Merge pull request 'Added registrar + fixed sign bug in car-body flow' (#10) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#10
2026-03-26 16:37:33 +03:30
8af152abc0 Added registrar + fixed sign bug in car-body flow 2026-03-26 16:35:02 +03:30
0446c83b36 Merge pull request 'update the blame service' (#9) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#9
2026-03-18 15:53:40 +03:30
255cfd3eb3 Merge pull request 'Disabled Inquiry' (#8) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#8
2026-03-17 18:55:18 +03:30
b1be5b1e09 Disabled Inquiry 2026-03-17 18:53:47 +03:30
7d3565ec51 Merge pull request 'Added Expert initiated flow (field expert) + deactivated inquiry' (#7) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#7
2026-03-16 15:45:49 +03:30
fc599acf4a Added Field Expert flows, and deactivated inquiry 2026-03-16 15:39:39 +03:30
SepehrYahyaee
b40270f058 Added refactored version of car-body 2026-03-14 13:36:05 +03:30
Soheil Hajizadeh
8d6fa3daac update the blame service 2026-03-10 01:06:30 +03:30
64dfd1ca8a Merge pull request 'historical refactored yara724 merge pull request' (#6) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#6
2026-02-24 12:24:56 +03:30
665ed9e70f Merge pull request 'blame and claim refactored' (#1) from integrate-my-work into main
Reviewed-on: #1
2026-02-24 12:21:43 +03:30
81 changed files with 9895 additions and 3499 deletions

2846
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@
"class-validator": "^0.14.1",
"crypto": "^1.0.1",
"dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2",

View File

@@ -3,6 +3,9 @@ export enum WorkflowStep {
CREATED = "CREATED",
// ---------- CAR_BODY only (no confession; accident type form) ----------
CAR_BODY_ACCIDENT_TYPE = "CAR_BODY_ACCIDENT_TYPE",
// ---------- First party ----------
FIRST_BLAME_CONFESSION = "FIRST_BLAME_CONFESSION",
FIRST_VIDEO = "FIRST_VIDEO",

View File

@@ -15,5 +15,7 @@ export enum CaseStatus {
CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED"
AUTO_CLOSED = "AUTO_CLOSED",
STOPPED = "STOPPED"
}

View 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];
}

View File

@@ -9,4 +9,7 @@ export enum ResendItemType {
// Media evidence
VOICE = "voice",
VIDEO = "video",
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
DESCRIPTION = "description",
}

View File

@@ -11,6 +11,9 @@ export enum ClaimCaseStatus {
// User flow - damage capture
CAPTURING_PART_DAMAGES = "CAPTURING_PART_DAMAGES",
/** Damage expert asked for more documents and/or part photos; owner must upload before the case returns to the expert queue. */
WAITING_FOR_USER_RESEND = "WAITING_FOR_USER_RESEND",
// Expert flow
WAITING_FOR_DAMAGE_EXPERT = "WAITING_FOR_DAMAGE_EXPERT",
EXPERT_REVIEWING = "EXPERT_REVIEWING",

View File

@@ -7,17 +7,22 @@ export enum ClaimWorkflowStep {
SELECT_OUTER_PARTS = "SELECT_OUTER_PARTS",
SELECT_OTHER_PARTS = "SELECT_OTHER_PARTS",
// User: Documentation phase
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User: Damage capture phase (per part)
// User: Damage capture phase (angles + per-part photos) — before document upload in v2
CAPTURE_PART_DAMAGES = "CAPTURE_PART_DAMAGES",
// User: Documentation phase — after captures in v2
UPLOAD_REQUIRED_DOCUMENTS = "UPLOAD_REQUIRED_DOCUMENTS",
// User submission complete
USER_SUBMISSION_COMPLETE = "USER_SUBMISSION_COMPLETE",
/** Owner fulfilling damage expert resend (extra documents and/or part images). */
USER_EXPERT_RESEND = "USER_EXPERT_RESEND",
// Expert: Damage assessment
EXPERT_DAMAGE_ASSESSMENT = "EXPERT_DAMAGE_ASSESSMENT",
/** After user objection: damage experts last priced reply (stored in evaluation.damageExpertReplyFinal) */
EXPERT_FINAL_REPLY = "EXPERT_FINAL_REPLY",
EXPERT_COST_EVALUATION = "EXPERT_COST_EVALUATION",
// Insurer approval

View File

@@ -2,6 +2,9 @@ export enum ClaimRequiredDocumentType {
// Car green card
CAR_GREEN_CARD = "car_green_card",
/** National ID card (or similar); may be requested on resend even if not in the initial upload set. */
NATIONAL_CARD = "national_card",
// Damaged party documents
DAMAGED_DRIVING_LICENSE_BACK = "damaged_driving_license_back",
DAMAGED_DRIVING_LICENSE_FRONT = "damaged_driving_license_front",

View File

@@ -2,6 +2,7 @@ export enum RoleEnum {
EXPERT = "expert",
DAMAGE_EXPERT = "damage_expert",
FIELD_EXPERT = "field_expert",
REGISTRAR = "registrar",
COMPANY = "company",
ADMIN = "admin",
USER = "user",

View File

@@ -23,6 +23,7 @@ import {
import { LoginActorDto } from "src/auth/dto/actor/login.actor.dto";
import { ActorEditUserProfileDto } from "src/auth/dto/actor/profile.actor.dto";
import { CreateFieldExpertDto } from "src/auth/dto/actor/create-field-expert.actor.dto";
import { CreateRegistrarDto } from "src/auth/dto/actor/create-registrar.actor.dto";
import {
GenuineRegisterDto,
InsurerRegisterDto,
@@ -64,6 +65,14 @@ export class ActorAuthController {
return await this.actorAuthService.createFieldExpertMock(body);
}
/** Mock: create a registrar for testing. Make private later. */
@Post("create-registrar")
@ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) {
return await this.actorAuthService.createRegistrarMock(body);
}
@UseGuards(LocalActorAuthGuard)
@Post("login")
@Roles()

View File

@@ -25,6 +25,7 @@ import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-ex
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
import { HashService } from "src/utils/hash/hash.service";
// import { MailService } from "src/utils/mail/mail.service";
import { OtpService } from "src/utils/otp/otp.service";
@@ -46,6 +47,7 @@ export class ActorAuthService {
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
private readonly insurerExpertDbService: InsurerExpertDbService,
// private readonly mailService: MailService, // Mailer disabled not used
private readonly clientDbService: ClientDbService,
@@ -79,6 +81,13 @@ export class ActorAuthService {
else
res = await this.fieldExpertDbService.findOne({ email: username });
break;
case RoleEnum.REGISTRAR:
if (username == null && userId)
res = await this.registrarDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.registrarDbService.findOne({ email: username });
break;
case RoleEnum.COMPANY:
res = await this.insurerExpertDbService.findOne({ email: username });
break;
@@ -273,6 +282,10 @@ export class ActorAuthService {
actor = await this.fieldExpertDbService.findOne(filter);
dbServiceToUpdate = this.fieldExpertDbService as any;
}
if (!actor) {
actor = await this.registrarDbService.findOne(filter);
dbServiceToUpdate = this.registrarDbService as any;
}
if (!actor) {
throw new NotFoundException("actor not found");
@@ -309,6 +322,10 @@ export class ActorAuthService {
userExist = await this.fieldExpertDbService.findOne({ email });
dbServiceToUpdate = this.fieldExpertDbService;
}
if (!userExist) {
userExist = await this.registrarDbService.findOne({ email });
dbServiceToUpdate = this.registrarDbService as any;
}
if (!userExist) throw new NotFoundException("user not found");
const decodeOtp = await this.hashService.compare(otp, userExist.otp);
if (!decodeOtp) throw new UnauthorizedException("otp invalid");
@@ -383,6 +400,7 @@ export class ActorAuthService {
"phone",
"mobile",
],
registrar: ["email"],
};
const allowedFields = allowedFieldsByRole[role];
@@ -498,4 +516,34 @@ export class ActorAuthService {
throw er;
}
}
async createRegistrarMock(body: {
email: string;
password: string;
clientId: string;
}) {
const hashPassword = await this.hashService.hash(body.password);
const payload = {
email: body.email.toLowerCase().trim(),
password: hashPassword,
clientKey: new Types.ObjectId(body.clientId),
role: RoleEnum.REGISTRAR,
};
try {
const created = await this.registrarDbService.create(payload as any);
return {
id: (created as any)._id,
email: (created as any).email,
clientKey: (created as any).clientKey,
role: (created as any).role,
};
} catch (er) {
if (er.code === 11000) {
throw new BadRequestException(
"A registrar with this email already exists.",
);
}
throw er;
}
}
}

View File

@@ -14,6 +14,7 @@ import { UserDbService } from "src/users/entities/db-service/user.db.service";
import { HashService } from "src/utils/hash/hash.service";
import { OtpService } from "src/utils/otp/otp.service";
import { SmsManagerService } from "src/utils/sms-manager/sms-manager.service";
import { describeSmsError } from "src/utils/sms-manager/sms-provider.exception";
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
@Injectable()
@@ -71,6 +72,12 @@ export class UserAuthService {
});
const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) {
await this.smsSender(otp, mobile);
/// create otp request
@@ -90,9 +97,7 @@ export class UserAuthService {
city: "",
address: "",
state: "",
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
});
return new LoginDtoRs(newUser);
}
@@ -105,9 +110,7 @@ export class UserAuthService {
},
{
otp: hashOtp,
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
},
);
if (updateTokens) return new LoginDtoRs(userExist);
@@ -115,25 +118,26 @@ export class UserAuthService {
}
private async smsSender(otp: string, mobile: string) {
return this.smsManagerService
.verifyLookUp({
try {
await this.smsManagerService.verifyLookUp({
token: otp,
template: process.env.AUTH_SMS_TEMPLATE,
receptor: mobile,
})
.then((smsRes) => {
});
this.logger.log(
`${"phone : " + mobile + " " + ", status : " + smsRes["return"].status + ", otp : " + otp} `,
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
);
})
.catch((er) => {
} catch (err) {
this.logger.error(
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
);
if (err instanceof HttpException) {
throw err;
}
throw new HttpException(
"auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR,
HttpStatus.BAD_GATEWAY,
);
});
}
}
}

View 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;
}

View File

@@ -44,6 +44,7 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
RoleEnum.DAMAGE_EXPERT,
RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
].includes(payload.role)
) {
throw new UnauthorizedException("User role is not authorized");

View File

@@ -14,21 +14,21 @@ export class GlobalGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.SECRET}`,
});
if (payload.role !== RoleEnum.USER) {
console.log(
"🚀 ~ GlobalGuard ~ canActivate ~ request.user.role:",
request.user.role,
);
if (payload.role !== RoleEnum.USER && payload.role !== RoleEnum.FIELD_EXPERT) {
throw new UnauthorizedException();
}
request.user = payload;
request.identity = request.user;
} catch {

View File

@@ -6,6 +6,7 @@ import { RequestManagementModule } from "src/request-management/request-manageme
import { UsersModule } from "src/users/users.module";
import { ClaimRequestManagementController } from "./claim-request-management.controller";
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
import { ClaimRequestManagementService } from "./claim-request-management.service";
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
@@ -85,7 +86,11 @@ import { JwtModule } from "@nestjs/jwt";
ClaimRequiredDocumentDbService,
ClaimAccessGuard,
],
controllers: [ClaimRequestManagementController, ClaimRequestManagementV2Controller],
controllers: [
ClaimRequestManagementController,
ClaimRequestManagementV2Controller,
RegistrarClaimV1Controller,
],
exports: [
ClaimRequestManagementService,
ClaimRequestManagementDbService,

View File

@@ -5,6 +5,7 @@ import {
Param,
Post,
Patch,
Put,
Body,
UseGuards,
Get,
@@ -25,15 +26,21 @@ import { SelectOuterPartsV2Dto, SelectOuterPartsV2ResponseDto } from "./dto/sele
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
import { CapturePartV2Dto, CapturePartV2ResponseDto } from "./dto/capture-part-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
VideoCaptureV2ResponseDto,
} from "./dto/capture-part-v2.dto";
import { GetMyClaimsV2ResponseDto } from "./dto/my-claims-v2.dto";
import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
import { UserObjectionV2Dto } from "./dto/user-objection-v2.dto";
import { UserRatingDto } from "./dto/user-rating.dto";
@ApiTags("claim-request-management (v2)")
@Controller("v2/claim-request-management")
@ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER)
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
export class ClaimRequestManagementV2Controller {
constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -51,7 +58,7 @@ export class ClaimRequestManagementV2Controller {
})
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub);
return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
@@ -91,6 +98,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -100,6 +108,120 @@ export class ClaimRequestManagementV2Controller {
}
}
/**
* V2: Acknowledge a damage-expert resend that only contains instructions (no extra documents or part photos).
*/
@Post("request/:claimRequestId/expert-resend/acknowledge")
@ApiOperation({
summary: "Acknowledge expert resend (instructions only)",
description:
"Use when `workflow.currentStep` is USER_EXPERT_RESEND and the expert did not list any `resendDocuments` or `resendCarParts`. " +
"Returns the claim to WAITING_FOR_DAMAGE_EXPERT. If documents or parts were requested, upload them via the existing upload/capture endpoints instead.",
})
@ApiParam({ name: "claimRequestId" })
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
async acknowledgeExpertResend(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.acknowledgeExpertResendInstructionsV2(
claimRequestId,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
);
}
}
/**
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
*/
@Put("request/:claimRequestId/objection")
@ApiOperation({
summary: "Submit user objection (V2)",
description:
"After the damage expert submits a resend request (`damageExpertResend`), the owner may dispute priced parts " +
"and/or propose additional damaged parts. Stores a structured payload on `evaluation.objection`, merges " +
"`newParts` into `damage.selectedParts`, and moves the case back to `WAITING_FOR_DAMAGE_EXPERT` for re-review.",
})
@ApiParam({
name: "claimRequestId",
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiBody({ type: UserObjectionV2Dto })
@ApiResponse({ status: 200, description: "Objection stored" })
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
@ApiResponse({ status: 403, description: "Not the claim owner" })
@ApiResponse({ status: 404, description: "Claim not found" })
@ApiResponse({ status: 409, description: "Objection already submitted" })
async submitUserObjectionV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UserObjectionV2Dto,
@CurrentUser() user: any,
) {
try {
return await this.claimRequestManagementService.handleUserObjectionV2(
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to submit objection",
);
}
}
/**
* V2: User satisfaction rating after the claim case is completed (same intent as v1 PUT …/request/:id/user-rating).
*/
@Put("request/:claimRequestId/user-rating")
@ApiOperation({
summary: "Submit user satisfaction rating (V2)",
description:
"Only the claim owner (damaged party) may submit. Allowed when `status` is `COMPLETED`. " +
"Stores scores on `ClaimCase.userRating` (05). 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")
@ApiParam({
name: "blameRequestId",
@@ -231,6 +353,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -252,13 +375,14 @@ export class ClaimRequestManagementV2Controller {
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
**Purpose:** User selects non-body damaged parts and provides bank information for payment.
Optional: upload car green card file in the same step.
**Validations:**
- Claim must exist
- User must be the claim owner (damaged party from blame case)
- Current workflow step must be SELECT_OTHER_PARTS
- Bank information must not have been submitted previously
- Sheba number must be exactly 24 digits
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
- National code must be exactly 10 digits
**Valid Other Parts (Optional):**
@@ -267,8 +391,8 @@ export class ClaimRequestManagementV2Controller {
- headlight, taillight, mirror, glass
**After Success:**
- Workflow moves to: UPLOAD_REQUIRED_DOCUMENTS (Step 4)
- User can proceed to upload required documents
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
- User captures car angles and damaged-part photos, then uploads required documents (Step 5)
`,
})
@ApiParam({
@@ -276,42 +400,38 @@ export class ClaimRequestManagementV2Controller {
description: "The claim case ID (MongoDB ObjectId)",
example: "507f1f77bcf86cd799439011",
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: 10 * 1024 * 1024 },
storage: diskStorage({
destination: "./files/claim-required-document",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `other-parts-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
type: SelectOtherPartsV2Dto,
description: "Other parts selection and bank information",
examples: {
example1: {
summary: "Only bank info (no other parts damaged)",
value: {
otherParts: [],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
},
},
example2: {
summary: "Engine and suspension damage",
value: {
otherParts: ["engine", "suspension"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
},
},
example3: {
summary: "Multiple systems damaged",
value: {
otherParts: ["engine", "brake_system", "electrical", "headlight"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
},
},
example4: {
summary: "Lighting and glass damage",
value: {
otherParts: ["headlight", "taillight", "mirror", "glass"],
shebaNumber: "123456789012345678901234",
nationalCodeOfOwner: "1234567890",
description:
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer` like THIRD_PARTY flow. Optional file can be uploaded as car green card.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{ type: "array", items: { type: "string" } },
{ type: "string", description: "JSON string array for multipart" },
],
example: ["engine", "suspension"],
},
sheba: { type: "string", example: "IR123456789012345678901234" },
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
file: { type: "string", format: "binary" },
},
required: ["sheba", "nationalCodeOfInsurer"],
},
})
@ApiResponse({
@@ -339,12 +459,15 @@ export class ClaimRequestManagementV2Controller {
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV2Dto,
@CurrentUser() user: any,
@UploadedFile() file?: Express.Multer.File,
): Promise<SelectOtherPartsV2ResponseDto> {
try {
return await this.claimRequestManagementService.selectOtherPartsV2(
claimRequestId,
body,
user.sub,
user,
file,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -369,6 +492,8 @@ export class ClaimRequestManagementV2Controller {
- Damaged parts (based on selected outer parts)
Returns status of each item (uploaded/captured or not).
**V2 order:** Complete angles and part photos first, then required documents.
`,
})
@ApiParam({
@@ -397,6 +522,7 @@ Returns status of each item (uploaded/captured or not).
return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -430,20 +556,20 @@ Returns status of each item (uploaded/captured or not).
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Upload Required Document (V2 - Step 4)",
summary: "Upload Required Document (V2 - Step 5)",
description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
**Upload one of the 13 required documents:**
**Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card
- damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back
- guilty_car_card_front/back, guilty_metal_plate
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
**When all 13 documents are uploaded:**
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
`,
})
@ApiParam({
@@ -459,7 +585,6 @@ Returns status of each item (uploaded/captured or not).
documentKey: {
type: "string",
enum: [
"car_green_card",
"damaged_driving_license_front",
"damaged_driving_license_back",
"damaged_chassis_number",
@@ -473,7 +598,7 @@ Returns status of each item (uploaded/captured or not).
"guilty_car_card_back",
"guilty_metal_plate",
],
example: "car_green_card",
example: "damaged_driving_license_front",
},
file: {
type: "string",
@@ -507,6 +632,7 @@ Returns status of each item (uploaded/captured or not).
body,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -538,15 +664,17 @@ Returns status of each item (uploaded/captured or not).
)
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Capture Car Angle or Damaged Part (V2 - Step 5)",
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
description: `
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 5 of Claim)
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
**Capture types:**
1. **angle**: Car angles (front, back, left, right) - 4 required
2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.**
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
`,
})
@ApiParam({
@@ -598,6 +726,7 @@ Returns status of each item (uploaded/captured or not).
body,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -606,4 +735,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",
);
}
}
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CarAngle } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
@@ -78,4 +78,32 @@ export class CapturePartV2ResponseDto {
example: 'Angle captured successfully. 6 captures remaining.',
})
message: string;
@ApiPropertyOptional({
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
})
expertResendComplete?: boolean;
}
/**
* Response DTO for car walk-around video upload (V2)
*/
export class VideoCaptureV2ResponseDto {
@ApiProperty({
description: 'Claim case ID',
example: '507f1f77bcf86cd799439011',
})
claimRequestId: string;
@ApiProperty({
description: 'ID of the stored video document (claim-video-capture)',
example: '507f1f77bcf86cd799439012',
})
videoId: string;
@ApiProperty({
description: 'Success message',
example: 'Video capture uploaded successfully.',
})
message: string;
}

View File

@@ -1,5 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** Active damage-expert resend request (owner must upload or acknowledge). */
export class ExpertResendDetailsV2Dto {
@ApiPropertyOptional()
resendDescription?: string;
@ApiPropertyOptional({ type: [String] })
resendDocuments?: string[];
@ApiPropertyOptional({ type: [Object] })
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
fulfilledAt?: Date;
}
export class ClaimDetailsV2ResponseDto {
@ApiProperty({ description: 'Claim case ID' })
claimRequestId: string;
@@ -63,6 +78,21 @@ export class ClaimDetailsV2ResponseDto {
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
'Damage expert resend instructions and progress (when status is WAITING_FOR_USER_RESEND).',
})
expertResend?: ExpertResendDetailsV2Dto;
@ApiPropertyOptional({ description: 'User satisfaction rating (if submitted)' })
userRating?: {
progressSpeed: number;
registrationEase: number;
overallEvaluation: number;
comment?: string;
createdAt?: Date;
};
@ApiProperty({ description: 'Created at' })
createdAt: string;

View File

@@ -41,34 +41,43 @@ export class SelectOtherPartsV2Dto {
otherParts?: OtherCarPart[];
@ApiProperty({
description: 'Sheba number (IBAN) for payment - 24 digits without IR prefix',
description: 'Sheba number (IBAN). Accepted: IR + 24 digits OR only 24 digits',
example: 'IR123456789012345678901234',
})
@IsNotEmpty({ message: 'sheba is required' })
@IsString({ message: 'sheba must be a string' })
sheba: string;
@ApiPropertyOptional({
description: 'Legacy alias of sheba for backward compatibility',
example: '123456789012345678901234',
pattern: '^[0-9]{24}$',
minLength: 24,
maxLength: 24,
})
@IsNotEmpty({ message: 'Sheba number is required' })
@IsString({ message: 'Sheba number must be a string' })
@Length(24, 24, { message: 'Sheba number must be exactly 24 digits' })
@Matches(/^[0-9]{24}$/, {
message: 'Sheba number must contain exactly 24 digits (without IR prefix)',
})
shebaNumber: string;
@IsOptional()
@IsString()
shebaNumber?: string;
@ApiProperty({
description: 'National code of the owner - 10 digits',
description: 'National code of insurer/owner - 10 digits',
example: '1234567890',
pattern: '^[0-9]{10}$',
minLength: 10,
maxLength: 10,
})
@IsNotEmpty({ message: 'National code of owner is required' })
@IsNotEmpty({ message: 'nationalCodeOfInsurer is required' })
@IsString({ message: 'National code must be a string' })
@Length(10, 10, { message: 'National code must be exactly 10 digits' })
@Matches(/^[0-9]{10}$/, {
message: 'National code must contain exactly 10 digits',
})
nationalCodeOfOwner: string;
nationalCodeOfInsurer: string;
@ApiPropertyOptional({
description: 'Legacy alias for backward compatibility',
example: '1234567890',
})
@IsOptional()
@IsString()
nationalCodeOfOwner?: string;
}
/**
@@ -108,19 +117,20 @@ export class SelectOtherPartsV2ResponseDto {
@ApiProperty({
description: 'Current workflow step',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
example: 'CAPTURE_PART_DAMAGES',
})
currentStep: string;
@ApiProperty({
description: 'Next possible workflow step',
example: 'CAPTURE_PART_DAMAGES',
example: 'UPLOAD_REQUIRED_DOCUMENTS',
})
nextStep: string;
@ApiProperty({
description: 'Success message',
example: 'Other parts and bank information saved successfully. Please proceed to upload required documents.',
example:
'Other parts and bank information saved successfully. Please capture car angles and damaged parts, then upload required documents.',
})
message: string;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
@@ -64,4 +64,9 @@ export class UploadRequiredDocumentV2ResponseDto {
example: 'Document uploaded successfully. 12 documents remaining.',
})
message: string;
@ApiPropertyOptional({
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
})
expertResendComplete?: boolean;
}

View 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[];
}

View File

@@ -29,6 +29,16 @@ export class ClaimCaseDbService {
return this.claimCaseModel.findByIdAndUpdate(id, update, { new: true });
}
async findOneAndUpdate(
filter: FilterQuery<ClaimCase>,
update: any,
options?: { new?: boolean },
): Promise<ClaimCaseDocument | null> {
return this.claimCaseModel.findOneAndUpdate(filter, update, {
new: options?.new !== false,
});
}
async find(
filter: FilterQuery<ClaimCase>,
options?: { lean?: boolean; select?: string },

View File

@@ -87,8 +87,9 @@ export class ClaimExpertReply {
export const ClaimExpertReplySchema =
SchemaFactory.createForClass(ClaimExpertReply);
/** One line item the user disputes on an expert-priced part */
@Schema({ _id: false })
export class ClaimUserObjection {
export class ClaimUserObjectionPart {
@Prop({ type: String, required: true })
partId: string;
@@ -103,9 +104,47 @@ export class ClaimUserObjection {
@Prop({ type: String })
typeOfDamage?: string;
@Prop({ type: String })
carPartDamage?: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionSchema =
SchemaFactory.createForClass(ClaimUserObjection);
export const ClaimUserObjectionPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionPart);
@Schema({ _id: false })
export class ClaimUserObjectionNewPart {
@Prop({ type: String })
partId?: string;
@Prop({ type: String, required: true })
partName: string;
@Prop({ type: String })
side?: string;
}
export const ClaimUserObjectionNewPartSchema =
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
/**
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
* Stored on {@link ClaimEvaluation.objection}.
*/
@Schema({ _id: false })
export class ClaimUserObjectionPayload {
@Prop({ type: [ClaimUserObjectionPartSchema], default: [] })
objectionParts?: ClaimUserObjectionPart[];
@Prop({ type: [ClaimUserObjectionNewPartSchema], default: [] })
newParts?: ClaimUserObjectionNewPart[];
@Prop({ type: Date, default: () => new Date() })
submittedAt?: Date;
}
export const ClaimUserObjectionPayloadSchema =
SchemaFactory.createForClass(ClaimUserObjectionPayload);
@Schema({ _id: false })
export class ClaimResendRequest {
@@ -117,6 +156,10 @@ export class ClaimResendRequest {
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
resendCarParts?: any[];
/** Set when the owner has satisfied every requested document/part (or acknowledged description-only resend). */
@Prop({ type: Date })
fulfilledAt?: Date;
}
export const ClaimResendRequestSchema =
SchemaFactory.createForClass(ClaimResendRequest);
@@ -182,8 +225,8 @@ export class ClaimEvaluation {
@Prop({ type: ClaimResendRequestSchema })
damageExpertResend?: ClaimResendRequest | null;
@Prop({ type: ClaimUserObjectionSchema })
objection?: ClaimUserObjection;
@Prop({ type: ClaimUserObjectionPayloadSchema })
objection?: ClaimUserObjectionPayload;
@Prop({ type: ClaimUserResendPayloadSchema })
userResendDocuments?: ClaimUserResendPayload;

View File

@@ -50,6 +50,9 @@ export class ClaimOwner {
@Prop({ type: String })
fullName?: string;
@Prop({ type: Types.ObjectId })
clientId: Types.ObjectId;
}
export const ClaimOwnerSchema = SchemaFactory.createForClass(ClaimOwner);

View File

@@ -26,6 +26,7 @@ import {
ClaimCaseSnapshotSchema,
} from "./claim-case.snapshot.schema";
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
import { UserClaimRating } from "./claim-request-management.schema";
@Schema({ _id: false })
export class RequiredDocumentRef {
@@ -75,6 +76,12 @@ export class ClaimCase {
@Prop({ type: String, enum: ClaimStatus, default: ClaimStatus.PENDING })
claimStatus: ClaimStatus;
/**
* Set while the linked blame file is in WAITING_FOR_DOCUMENT_RESEND so the claim flow can block or message the user.
*/
@Prop({ default: false })
blameDocumentResendPending?: boolean;
/**
* Link to the blame case that originated this claim.
* IMPORTANT: this is intentionally ONLY an id reference (no embedded blame file).
@@ -133,6 +140,15 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[];
/**
* User satisfaction rating (damaged party), after the case is completed.
*/
@Prop({ type: UserClaimRating, required: false })
userRating?: UserClaimRating;
@Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId;
/**
* Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these.

View 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,
);
}
}

View File

@@ -7,6 +7,11 @@ import {
Logger,
NotFoundException,
} 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 { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
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 { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.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 { toJalaliDateAndTime } from "src/helpers/date-jalali";
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 { 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 { RequestManagementService } from "src/request-management/request-management.service";
interface CheckedRequestEntry {
CheckedRequest?: {
@@ -58,6 +65,10 @@ function statementToFormKey(
@Injectable()
export class ExpertBlameService {
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(
private readonly requestManagementDbService: RequestManagementDbService,
private readonly blameRequestDbService: BlameRequestDbService,
@@ -67,6 +78,7 @@ export class ExpertBlameService {
private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
private readonly requestManagementService: RequestManagementService,
) { }
async findAll(actor: any): Promise<AllRequestDtoRs> {
@@ -185,6 +197,7 @@ export class ExpertBlameService {
*/
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try {
requireActorClientKey(actor);
const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
@@ -196,9 +209,25 @@ export class ExpertBlameService {
);
// Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert
// 1. Same insurance tenant (party clientId or expert-initiated by this actor)
// 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) => {
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 decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId;
@@ -223,6 +252,35 @@ export class ExpertBlameService {
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(
(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) {
return setTimeout(async () => {
try {
@@ -509,12 +636,15 @@ export class ExpertBlameService {
* Excludes history. Returns only nonCAR_BODY types. Builds file links for all evidence.
* 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 {
requireActorClientKey(actor);
const actorId = actor.sub;
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(doc, actor);
const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException(
@@ -522,81 +652,125 @@ 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 decidedByExpertId = decision?.decidedByExpertId;
// Allow if:
// 1. No decision yet (fresh request)
// 2. Decision made by current expert
if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.",
);
}
const parties = (doc.parties ?? []) as Array<{
evidence?: { videoId?: string; voices?: string[] };
[key: string]: unknown;
const parties = Array.isArray(doc.parties) ? doc.parties : [];
const typedParties = parties as Array<{
vehicle?: {
inquiry?: {
mapped?: any;
};
};
evidence?: {
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
}>;
for (const party of parties) {
// Evidence (videos + voices)
for (const party of typedParties) {
if (!party.evidence) continue;
const evidence = party.evidence as Record<string, unknown>;
if (evidence.videoId) {
const videoDoc = await this.blameVideoDbService.findById(
String(evidence.videoId),
);
if (videoDoc?.path) {
evidence.videoUrl = buildFileLink(videoDoc.path);
}
const videoDoc = await this.blameVideoDbService.findById(String(evidence.videoId));
if (videoDoc?.path) evidence.videoUrl = buildFileLink(videoDoc.path);
}
if (evidence.voices && Array.isArray(evidence.voices)) {
const voiceUrls: string[] = [];
for (const voiceId of evidence.voices) {
const voiceDoc = await this.blameVoiceDbService.findById(
String(voiceId),
);
if (voiceDoc?.path) {
voiceUrls.push(buildFileLink(voiceDoc.path));
}
const voiceDoc = await this.blameVoiceDbService.findById(String(voiceId));
if (voiceDoc?.path) voiceUrls.push(buildFileLink(voiceDoc.path));
}
evidence.voiceUrls = voiceUrls;
}
}
const createdAt = doc.createdAt ? new Date(doc.createdAt as string | Date) : new Date();
const updatedAt = doc.updatedAt ? new Date(doc.updatedAt as string | Date) : new Date();
// Date formatting
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 [updatedDate, updatedTime] = toJalaliDateAndTime(updatedAt);
doc.createdAtFormatted = `${createdDate} ${createdTime}`;
doc.updatedAtFormatted = `${updatedDate} ${updatedTime}`;
// Exclude mapped inquiry vehicle for both parties from response
for (const party of parties) {
delete party.vehicle;
// Omit heavy SandHub inquiry blob from expert response (keep other vehicle fields)
for (const party of typedParties) {
const veh = party?.vehicle as Record<string, unknown> | undefined;
if (veh && Object.prototype.hasOwnProperty.call(veh, "inquiry")) {
delete veh.inquiry;
}
}
return doc;
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(
"findOneV2 failed",
requestId,
error instanceof Error ? error.stack : String(error),
);
throw new InternalServerErrorException(
error instanceof Error ? error.message : "Failed to get blame case details",
);
}
}
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 updateResult = await this.requestManagementDbService.findOneAndUpdate(
{
_id: requestId,
lockFile: false,
blameStatus: { $ne: ReqBlameStatus.UserPending },
},
{
@@ -642,14 +816,16 @@ export class ExpertBlameService {
async lockRequestV2(requestId: string, actorDetail: any): Promise<{ _id: string; lock: boolean }> {
try {
const now = new Date();
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
// First, check the current state
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actorDetail);
// Validate request is available for expert review
if (
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
@@ -660,14 +836,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
if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() < lockExpiryTime) {
// Lock is still valid
const lockedByActorId = String(request.workflow.lockedBy?.actorId);
const lockedByActorId = String(
request.workflow.lockedBy?.actorId ?? "",
);
if (lockedByActorId === actorDetail.sub) {
throw new BadRequestException(
"You have already locked this request",
@@ -726,15 +916,30 @@ export class ExpertBlameService {
async resendRequestV2(
requestId: string,
resendDto: ResendRequestDto,
actorId: string,
): Promise<{ requestId: string; status: string }> {
actor: any,
): Promise<{
requestId: string;
status: string;
parties: Array<{
partyId: string;
requestedItems: ResendItemType[];
requestedItemsUi: ReturnType<typeof buildResendItemsWithUi>;
description?: string;
}>;
}> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate request is locked by current expert
if (!request.workflow?.locked) {
throw new ForbiddenException(
@@ -752,7 +957,8 @@ export class ExpertBlameService {
// Validate lock hasn't expired
const lockedAt = request.workflow?.lockedAt;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
if (Date.now() > lockExpiryTime) {
throw new ForbiddenException(
"Your lock time has expired. Please lock the request again.",
@@ -774,16 +980,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();
// Build resend requests for parties
const partyResendRequests = resendDto.parties.map((party) => ({
partyId: new Types.ObjectId(String(party.partyId)),
const partyResendRequests = resendDto.parties.map((party) => {
const uid = String(party.partyId);
return {
partyId: new Types.ObjectId(uid),
requestedItems: party.requestedItems,
description: party.description || "",
requestedAt: now,
completed: false,
}));
uploadedDocuments: {},
};
});
const updatePayload = {
$set: {
@@ -797,9 +1029,6 @@ export class ExpertBlameService {
},
status: CaseStatus.WAITING_FOR_DOCUMENT_RESEND,
},
$push: {
"workflow.completedSteps": "WAITING_FOR_GUILT_DECISION",
},
$unset: {
"workflow.lockedAt": "",
"workflow.lockedBy": "",
@@ -817,11 +1046,21 @@ export class ExpertBlameService {
);
}
await this.requestManagementService.applyLinkedClaimsBlameResendStarted(
requestId,
);
// TODO: Send notifications to parties (SMS/Push) about required documents
return {
requestId: String(request._id),
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) {
if (error instanceof HttpException) throw error;
@@ -839,19 +1078,30 @@ export class ExpertBlameService {
/**
* V2: Submit expert reply for blame case (blameCases collection).
* 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(
requestId: string,
reply: SubmitReplyDto,
actorId: string,
actor: any,
): Promise<{ requestId: string; status: string }> {
try {
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
requireActorClientKey(actor);
const actorId = actor.sub;
const request = await this.blameRequestDbService.findById(requestId);
if (!request) {
throw new NotFoundException("Request not found");
}
assertBlameCaseForExpertTenant(request, actor);
// Validate no decision exists yet
if (request.expert?.decision) {
throw new ForbiddenException(
@@ -873,7 +1123,8 @@ export class ExpertBlameService {
// Check if lock has expired (15 minutes)
let isLockExpired = false;
if (lockedAt) {
const lockExpiryTime = new Date(lockedAt).getTime() + 15 * 60 * 1000;
const lockExpiryTime =
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
isLockExpired = Date.now() > lockExpiryTime;
}
@@ -968,6 +1219,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(
expertId: string,
requestId: string,
@@ -1302,4 +1673,5 @@ export class ExpertBlameService {
return updated;
}
}

View File

@@ -22,7 +22,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT)
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) { }
@@ -42,7 +42,7 @@ export class ExpertBlameV2Controller {
@ApiParam({ name: "id", description: "Blame case request id" })
async findOne(@Param("id") id: string, @CurrentUser() actor: any) {
try {
return await this.expertBlameService.findOneV2(id, actor.sub);
return await this.expertBlameService.findOneV2(id, actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
@@ -73,7 +73,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.resendRequestV2(id, body, actor.sub);
return await this.expertBlameService.resendRequestV2(id, body, actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
@@ -93,7 +93,7 @@ export class ExpertBlameV2Controller {
@CurrentUser() actor: any,
) {
try {
return await this.expertBlameService.replyRequestV2(id, body, actor.sub);
return await this.expertBlameService.replyRequestV2(id, body, actor);
} catch (error) {
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(
@@ -101,4 +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",
);
}
}
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
export class ClaimDetailV2ResponseDto {
@ApiProperty()
@@ -43,6 +44,19 @@ export class ClaimDetailV2ResponseDto {
plate?: any;
};
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — another car (`car`) and/or object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiPropertyOptional({ description: 'Blame request ID', example: '507f...' })
blameRequestId?: string;
@@ -69,6 +83,52 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ description: 'Damaged parts captured' })
damagedParts?: Record<string, { captured: boolean; url?: string }>;
@ApiPropertyOptional({
description:
'True when user uploaded all required factors and the case awaits expert approve/reject.',
})
awaitingFactorValidation?: boolean;
@ApiPropertyOptional({
description:
'Expert reply payloads (first and/or final) when awaiting factor validation.',
})
evaluation?: {
damageExpertReply?: unknown;
damageExpertReplyFinal?: unknown;
};
@ApiPropertyOptional({
description:
'Car walk-around video from `claim-video-capture` (resolved from `media.videoCaptureId`).',
})
videoCapture?: {
id: string;
url?: string;
path?: string;
fileName?: string;
};
@ApiPropertyOptional({
description:
'Linked blame case (`blameCases`), same shape as expert-blame detail: parties with video/voice URLs, workflow, expert, formatted dates.',
})
blameCase?: Record<string, unknown>;
@ApiPropertyOptional({
description:
'Same as `blameCase.expert.decision` when present: guilty party id, description, accident fields, decidedAt.',
})
blameExpertDecision?: Record<string, unknown>;
@ApiPropertyOptional({
description: 'Claim payout / banking metadata from the claim file (e.g. Sheba).',
})
money?: {
sheba?: string;
nationalCodeOfInsurer?: string;
};
@ApiProperty()
createdAt: string;

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BlameRequestType } from 'src/Types&Enums/blame-request-management/blameRequestType.enum';
export class ClaimListItemV2Dto {
@ApiProperty({ description: 'Claim case ID' })
@@ -29,8 +30,28 @@ export class ClaimListItemV2Dto {
carType?: string;
};
@ApiPropertyOptional({
description: 'Blame file type from linked blame case',
enum: BlameRequestType,
example: BlameRequestType.THIRD_PARTY,
})
blameRequestType?: BlameRequestType;
@ApiPropertyOptional({
description:
'CAR_BODY only: first-step flags — accident involved another car (`car`) and/or fixed object (`object`)',
example: { car: true, object: false },
})
carBodyFirstForm?: { car?: boolean; object?: boolean };
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
createdAt: string;
@ApiPropertyOptional({
description:
'True when the claim is in the expert factor validation queue (all factors uploaded).',
})
awaitingFactorValidation?: boolean;
}
export class GetClaimListV2ResponseDto {

View File

@@ -6,8 +6,11 @@ import {
IsBoolean,
IsOptional,
ValidateNested,
IsEnum,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
import { DamagedPartItem } from 'src/claim-request-management/dto/capture-requirements-v2.dto';
export class CarPartDamageV2Dto {
@ApiProperty({ example: 'left' })
@@ -68,3 +71,30 @@ export class SubmitExpertReplyV2Dto {
@Type(() => PartPricingV2Dto)
parts: PartPricingV2Dto[];
}
export class ClaimSubmitResendV2Dto {
@ApiPropertyOptional({
description:
"Instructions for the owner. At least one of description, resendDocuments, or resendCarParts must be non-empty.",
})
@IsOptional()
@IsString()
resendDescription?: string;
@ApiPropertyOptional({
type: [String],
enum: ClaimRequiredDocumentType,
description: "Extra document keys to upload (may include types not in the initial claim form).",
})
@IsOptional()
@IsArray()
@IsEnum(ClaimRequiredDocumentType, { each: true })
resendDocuments?: ClaimRequiredDocumentType[];
@ApiPropertyOptional({ type: [DamagedPartItem] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => DamagedPartItem)
resendCarParts?: DamagedPartItem[];
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -31,3 +31,45 @@ export class FactorValidationDto {
@Type(() => FactorDecisionDto)
decisions: FactorDecisionDto[];
}
/** V2 ClaimCase: expert confirms or overrides part pricing when validating uploaded factors. */
class FactorDecisionV2Dto {
@ApiProperty()
@IsString()
partId: string;
@ApiProperty({ enum: [FactorStatus.APPROVED, FactorStatus.REJECTED] })
@IsEnum([FactorStatus.APPROVED, FactorStatus.REJECTED])
status: FactorStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
rejectionReason?: string;
@ApiPropertyOptional({
description:
"Expert-adjusted values (Persian/ASCII digits ok). For REJECTED factors, provide at least totalPayment (or price and salary).",
})
@IsOptional()
@IsString()
price?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
salary?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
totalPayment?: string;
}
export class FactorValidationV2Dto {
@ApiProperty({ type: [FactorDecisionV2Dto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => FactorDecisionV2Dto)
decisions: FactorDecisionV2Dto[];
}

View 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[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { Body, Controller, Get, Param, Patch, Put, UseGuards } from "@nestjs/common";
import { Body, Controller, Get, Header, Param, Headers, Patch, Put, UseGuards, Query, Res } from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiPropertyOptional,
ApiQuery,
ApiTags,
} from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
@@ -14,7 +15,9 @@ import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertClaimService } from "./expert-claim.service";
import { SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { ClaimSubmitResendV2Dto, SubmitExpertReplyV2Dto } from "./dto/expert-claim-v2.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { FactorValidationV2Dto } from "./dto/factor-validation.dto";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@@ -35,27 +38,24 @@ export class ExpertClaimV2Controller {
@ApiOperation({
summary: "List available claim requests for damage expert",
description:
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, plus this expert's own locked/in-progress claims.",
"Returns claims that are WAITING_FOR_DAMAGE_EXPERT and not locked by another expert, this expert's locked/in-progress claims, and claims awaiting factor validation (UNDER_REVIEW at EXPERT_COST_EVALUATION).",
})
async getClaimListV2(@CurrentUser() actor) {
return await this.expertClaimService.getClaimListV2(actor.sub);
return await this.expertClaimService.getClaimListV2(actor);
}
@Get("request/:claimRequestId")
@ApiOperation({
summary: "Get claim request detail for damage expert",
description:
"Returns full claim details including captured images, required documents status, and damage selections. Claim must have status WAITING_FOR_DAMAGE_EXPERT. If locked, only the locking expert can access it.",
"Returns full claim details including captured images, required documents, damage selections, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
})
@ApiParam({ name: "claimRequestId" })
async getClaimDetailV2(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() actor,
) {
return await this.expertClaimService.getClaimDetailV2(
claimRequestId,
actor.sub,
);
return await this.expertClaimService.getClaimDetailV2(claimRequestId, actor);
}
@Put("lock/:claimRequestId")
@@ -88,6 +88,23 @@ export class ExpertClaimV2Controller {
return await this.expertClaimService.submitExpertReplyV2(claimRequestId, body, actor);
}
@Put("reply/resend/:claimRequestId")
@ApiOperation({
summary: "Submit expert damage resend request",
description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"The owner uploads via the same document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto })
async submitExpertResendV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: ClaimSubmitResendV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.submitResendDocsV2(claimRequestId, body, actor);
}
@Patch(":claimRequestId/visit")
@ApiOperation({
summary: "Request in-person visit for claim",
@@ -107,4 +124,61 @@ export class ExpertClaimV2Controller {
body?.note,
);
}
@Patch("validate-factors/:claimRequestId")
@ApiOperation({
summary: "Validate uploaded repair factors (V2 ClaimCase)",
description:
"After all required factors are uploaded, expert approves or rejects each part. Rejected parts must include expert totalPayment (or price and salary). All approved → insurer review. Any rejected → expert repricing and case COMPLETED.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FactorValidationV2Dto })
async validateClaimFactorsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FactorValidationV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.validateClaimFactorsV2(
claimRequestId,
body,
actor,
);
}
@Patch("request/:claimRequestId/damaged-parts")
@ApiOperation({
summary: "Edit selected damaged parts (V2)",
description:
"Damage expert can modify selected damaged parts while claim is in EXPERT_REVIEWING and locked by the same expert.",
})
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: UpdateClaimDamagedPartsV2Dto })
async updateClaimDamagedPartsV2(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UpdateClaimDamagedPartsV2Dto,
@CurrentUser() actor,
) {
return await this.expertClaimService.updateClaimDamagedPartsV2(
claimRequestId,
body,
actor,
);
}
@ApiQuery({
name: "query",
enum: ["car-capture", "accident"],
required: true,
})
@Get("stream/:id/video")
@Header("Accept-Ranges", "bytes")
@Header("Content-Type", "video/mp4")
async claimStream(
@Headers() headers,
@Param("id") id: string,
@Query("query") query: "car-capture" | "accident",
@Res() res,
) {
return this.expertClaimService.streamServiceV2(id, query, res, headers);
}
}

View File

@@ -78,6 +78,7 @@ export class ExpertInsurerController {
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Get("/:expertId")
async requestDetail(
@CurrentUser() insurer,
@Param("expertId") id: string,
@Query("role") role: "claim" | "blame",
) {
@@ -89,7 +90,11 @@ export class ExpertInsurerController {
throw new BadRequestException("Invalid role");
}
return await this.expertInsurerService.getAllFilesByExpertAndRole(id, role);
return await this.expertInsurerService.getAllFilesByExpertAndRole(
id,
role,
insurer.clientKey,
);
}
@ApiBody({
@@ -153,6 +158,7 @@ export class ExpertInsurerController {
@ApiQuery({ name: "role", enum: ["claim", "blame"] })
@Put("/:requestId/rating")
async rateExperts(
@CurrentUser() insurer,
@Param("requestId") requestId: string,
@Body() rating: FileRating,
@Query("role") role: "claim" | "blame",
@@ -165,13 +171,13 @@ export class ExpertInsurerController {
requestId,
rating,
role,
insurer.clientKey,
);
}
@Get("branches/:insuranceId")
@ApiParam({ name: "insuranceId" })
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
console.log("insuranceId", insuranceId);
return await this.expertInsurerService.retrieveInsuranceBranches(
insuranceId,
);

View File

@@ -1,163 +1,159 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model, Types } from "mongoose";
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 { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { CreateBranchDto } from "src/client/dto/create-branch.dto";
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 {
FileRating,
RequestManagementModel,
} from "src/request-management/entities/schema/request-management.schema";
blameCaseTouchesClient,
claimCaseTouchesClient,
} 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 { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
@Injectable()
export class ExpertInsurerService {
private readonly logger = new Logger(ExpertInsurerService.name);
constructor(
private readonly client: ClientDbService,
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
@InjectModel(ClaimRequestManagementModel.name)
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
@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 blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly branchDbService: BranchDbService,
private readonly claimRequiredDocumentDbService: ClaimRequiredDocumentDbService,
private readonly damageImageDbService: DamageImageDbService,
) {}
async retrieveAllExpertsOfClient(
actor,
currentPage: number,
countPerPage: number,
) {
const { clientKey } = actor;
if (!clientKey) return;
private getClientId(actorOrId: any): Types.ObjectId {
const raw = typeof actorOrId === "string" ? actorOrId : actorOrId?.clientKey;
if (!raw || !Types.ObjectId.isValid(raw)) {
throw new BadRequestException("Client key is required");
}
return new Types.ObjectId(raw);
}
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([
this.expertDbService.findAll({ clientKey: clientKey }),
this.damageExpertDbService.findAll({ clientKey: clientObjectId }),
]);
const allExpertsRaw = [...experts, ...damageExperts];
const expertIds = allExpertsRaw.map((expert) => expert._id.toString());
private normalizeClaimCase(claim: any): any {
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,
};
}
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 },
],
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 },
},
{ "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 expertRatingsByCategoryMap: Record<
string,
Record<string, number[]>
> = {};
/**
* Converts insurer FileRating and optional userRating into a single
* combined score for a file.
*/
const getCombinedFileScore = (file: any): number | null => {
private 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),
(v): v is number => typeof v === "number" && !isNaN(v),
)
: [];
const insurerAvg =
insurerValues.length > 0
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 userValues = userRating
? [
userRating.progressSpeed,
userRating.registrationEase,
userRating.overallEvaluation,
].filter((val) => typeof val === "number" && !isNaN(val))
: [];
const userAvg =
userValues.length > 0
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));
}
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),
);
};
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));
}
const processRatings = (expertId: string, file: any) => {
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.getClientBlameFiles(clientObjectId),
this.getClientClaimFiles(clientObjectId),
]);
const allExpertsRaw = [...experts, ...damageExperts];
const expertTotalRatingsMap: Record<string, number[]> = {};
const expertRatingsByCategoryMap: Record<string, Record<string, number[]>> = {};
const processRatings = (expertId: string | undefined, file: any) => {
if (!expertId) return;
const rating = file?.rating;
const combinedScore = getCombinedFileScore(file);
// Aggregate overall combined score for this expert
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore !== null) {
if (!expertTotalRatingsMap[expertId])
expertTotalRatingsMap[expertId] = [];
if (!expertTotalRatingsMap[expertId]) expertTotalRatingsMap[expertId] = [];
expertTotalRatingsMap[expertId].push(combinedScore);
}
// Keep backward-compatible per-category insurer ratings
if (!rating || typeof rating !== "object") return;
if (!expertRatingsByCategoryMap[expertId]) {
expertRatingsByCategoryMap[expertId] = {};
}
if (!expertRatingsByCategoryMap[expertId]) expertRatingsByCategoryMap[expertId] = {};
for (const [category, value] of Object.entries(rating)) {
if (typeof value === "number" && !isNaN(value)) {
if (!expertRatingsByCategoryMap[expertId][category]) {
@@ -169,37 +165,29 @@ export class ExpertInsurerService {
};
for (const file of blameFiles) {
const expertId = file?.actorLocked?.actorId?.toString();
processRatings(expertId, file);
processRatings(file?.actorLocked?.actorId?.toString?.(), file);
}
for (const file of claimFiles) {
const expertId = file?.damageExpertReply?.actorDetail?.actorId;
processRatings(expertId, file);
processRatings(file?.damageExpertReply?.actorDetail?.actorId, file);
}
const allExperts = allExpertsRaw.map((expert) => {
const expertIdStr = expert._id.toString();
const totalRatings = expertTotalRatingsMap[expertIdStr] || [];
const overallAverageRating = totalRatings.length
? parseFloat(
(
totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length
).toFixed(2),
(totalRatings.reduce((a, b) => a + b, 0) / totalRatings.length).toFixed(2),
)
: null;
const averageRatingsByCategory: Record<string, number> = {};
const ratingsByCat = expertRatingsByCategoryMap[expertIdStr];
if (ratingsByCat) {
for (const [category, values] of Object.entries(ratingsByCat)) {
const sum = values.reduce((a, b) => a + b, 0);
const average = parseFloat((sum / values.length).toFixed(2));
averageRatingsByCategory[category] = average;
averageRatingsByCategory[category] = parseFloat(
(values.reduce((a, b) => a + b, 0) / values.length).toFixed(2),
);
}
}
return {
_id: expert._id,
fullName: `${expert.firstName} ${expert.lastName}`,
@@ -212,14 +200,14 @@ export class ExpertInsurerService {
};
});
const start = (currentPage - 1) * countPerPage;
const paginated = allExperts.slice(start, start + countPerPage);
const page = Number(currentPage) > 0 ? Number(currentPage) : 1;
const perPage = Number(countPerPage) > 0 ? Number(countPerPage) : 20;
const start = (page - 1) * perPage;
return {
total: allExperts.length,
page: currentPage,
countPerPage,
experts: paginated,
page,
countPerPage: perPage,
experts: allExperts.slice(start, start + perPage),
};
}
@@ -245,510 +233,125 @@ export class ExpertInsurerService {
* combined insurer + user ratings.
*/
async getTopFilesForClient(insurerId: string): Promise<any[]> {
const id = new Types.ObjectId(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 claimFiles = await this.getClientClaimFiles(this.getClientId(insurerId));
const scored = claimFiles
.map((file) => {
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;
const combinedScore =
scores.reduce((a, b) => a + b, 0) / scores.length;
return {
...file,
combinedScore: parseFloat(combinedScore.toFixed(2)),
};
const combinedScore = this.getCombinedFileScore(file);
if (combinedScore === null) return null;
return { ...file, combinedScore };
})
.filter((f) => f !== null);
const sorted = scored.sort((a, b) => b.combinedScore - a.combinedScore);
return sorted.slice(0, 10);
return scored.sort((a, b) => b.combinedScore - a.combinedScore).slice(0, 10);
}
async getAllFilesByExpertAndRole(expertId: string, role: "claim" | "blame") {
const expertObjectId = new Types.ObjectId(expertId);
const model =
role === "claim"
? this.claimRequestManagementModel
: this.requestManagementModel;
const expertCollection = role === "claim" ? "damage-expert" : "expert";
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,
},
},
},
private async assertExpertBelongsToInsurer(
expertObjectId: Types.ObjectId,
insurerClientKey: string,
) {
const ck = String(insurerClientKey);
const clientOid = new Types.ObjectId(ck);
const [experts, damageExperts] = await Promise.all([
this.expertDbService.findAll({ clientKey: ck }),
this.damageExpertDbService.findAll({ clientKey: clientOid }),
]);
// Process imageRequired for claim files
if (role === "claim") {
return await Promise.all(
results.map((file) => this.processImageRequired(file)),
const id = String(expertObjectId);
const ok = [...experts, ...damageExperts].some((e) => String(e._id) === id);
if (!ok) {
throw new ForbiddenException(
"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(
requestId: string,
rating: FileRating,
role: "claim" | "blame",
insurerClientKey: string,
) {
const _id = new Types.ObjectId(requestId);
// Validate each rating factor
const fields = Object.entries(rating);
for (const [key, value] of fields) {
const _id = this.parseObjectId(requestId, "request id");
const clientKeyStr = String(this.getClientId(insurerClientKey));
for (const [key, value] of Object.entries(rating || {})) {
if (typeof value !== "number" || value < 0 || value > 5) {
throw new BadRequestException(
`${key} must be a number between 0 and 5`,
);
throw new BadRequestException(`${key} must be a number between 0 and 5`);
}
}
if (role === "claim") {
const updated = await this.claimRequestManagementModel.findByIdAndUpdate(
_id,
{ $set: { rating } },
{ new: true },
);
if (!updated) {
throw new NotFoundException("Claim file not found");
const existing = await this.claimCaseDbService.findById(_id);
if (!existing) throw new NotFoundException("Claim file not found");
if (!claimCaseTouchesClient(existing as any, clientKeyStr)) {
throw new ForbiddenException("This claim does not belong to your organization.");
}
const updated = await this.claimCaseDbService.findByIdAndUpdate(_id, {
$set: { "evaluation.rating": rating },
});
if (!updated) throw new NotFoundException("Claim file not found");
return {
message: "Claim expert rated successfully",
updatedRating: updated.rating,
updatedRating: (updated as any)?.evaluation?.rating || rating,
requestId: updated._id,
};
}
if (role === "blame") {
const updated = await this.requestManagementModel.findByIdAndUpdate(
_id,
{ $set: { rating } },
{ new: true },
);
if (!updated) {
throw new NotFoundException("Blame file not found");
const existing = await this.blameRequestDbService.findById(_id);
if (!existing) throw new NotFoundException("Blame file not found");
if (!blameCaseTouchesClient(existing as any, clientKeyStr)) {
throw new ForbiddenException("This blame case does not belong to your organization.");
}
const updated = await this.blameRequestDbService.findByIdAndUpdate(_id, {
$set: { "expert.rating": rating },
});
if (!updated) throw new NotFoundException("Blame file not found");
return {
message: "Blame expert rated successfully",
updatedRating: updated.rating,
updatedRating: (updated as any)?.expert?.rating || rating,
requestId: updated._id,
};
}
throw new BadRequestException("Invalid role");
}
async retrieveAllFilesOfClient(insurerId: string) {
const id = new Types.ObjectId(insurerId);
const blameFilesRaw = await this.requestManagementModel
.find({
"firstPartyDetails.firstPartyClient.clientId": id,
blameStatus: {
$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;
}
}),
);
const id = this.getClientId(insurerId);
const [blameFiles, claimFiles] = await Promise.all([
this.getClientBlameFiles(id),
this.getClientClaimFiles(id),
]);
return { blameFiles, claimFiles };
}
async addBranch(clientKey: string, branchDto: CreateBranchDto) {
const clientId = new Types.ObjectId(clientKey);
const clientId = this.getClientId(clientKey);
const existingBranch = await this.branchDbService.findOne({
clientKey: clientId,
@@ -778,19 +381,8 @@ export class ExpertInsurerService {
* - Number of files created in the current month
*/
async getExpertStatisticsReport(actor: any) {
const { clientKey } = actor;
if (!clientKey) {
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();
const clientObjectId = this.getClientId(actor);
const claimFiles = await this.getClientClaimFiles(clientObjectId);
// Calculate current month date range
const now = new Date();
@@ -907,6 +499,6 @@ export class ExpertInsurerService {
}
async retrieveInsuranceBranches(insuranceId: string) {
return await this.branchDbService.findAll(insuranceId);
return this.branchDbService.findAll(insuranceId);
}
}

View File

@@ -0,0 +1,149 @@
import { Types } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
/** Default accident taxonomy when guilt is established by mutual party agreement (no field-expert decision). */
export const MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS = {
accidentWay: { id: 8, label: "جلو به عقب" },
accidentReason: {
id: 1,
fanavaran: 1,
label:
"عدم رعايت فاصله مناسب با وسيله نقليه و يا عدم توجه به جلو",
},
accidentType: {
id: 1,
label: "برخورد یک وسیله نقلیه با یک وسیله نقلیه",
},
} as const;
export type MutualAgreementDecisionPayload = {
guiltyPartyId: Types.ObjectId;
description: string;
decidedAt: Date;
decidedByExpertId?: Types.ObjectId;
fields: typeof MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS;
};
function partyPhone(p: { person?: { phoneNumber?: string } } | undefined): string {
return (p?.person?.phoneNumber || "").trim();
}
function partyUserId(
p: { person?: { userId?: Types.ObjectId } } | undefined,
): Types.ObjectId | null {
const id = p?.person?.userId;
if (!id) return null;
return id instanceof Types.ObjectId ? id : new Types.ObjectId(String(id));
}
/**
* When the first party chose AGREED (guilty or damaged) and the second party exists,
* derive the guilty party and build the synthetic expert.decision payload.
* Does not set decidedByExpertId — this is not a field-expert adjudication.
*/
export function buildMutualAgreementExpertDecision(
doc: Record<string, unknown>,
): MutualAgreementDecisionPayload | null {
if (doc.type !== BlameRequestType.THIRD_PARTY) return null;
if (doc.blameStatus !== BlameStatus.AGREED) return null;
const parties = (doc.parties ?? []) as Array<{
role?: string;
person?: { userId?: Types.ObjectId; phoneNumber?: string };
statement?: {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
}>;
const first = parties.find((p) => p.role === PartyRole.FIRST);
const second = parties.find((p) => p.role === PartyRole.SECOND);
if (!first || !second) return null;
const st = first.statement;
if (!st) return null;
if (st.acceptsExpertOpinion) return null;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return null;
let guiltyParty: typeof first;
let damagedParty: typeof first;
if (admits) {
guiltyParty = first;
damagedParty = second;
} else {
guiltyParty = second;
damagedParty = first;
}
const guiltyId = partyUserId(guiltyParty);
if (!guiltyId) return null;
const gPhone = partyPhone(guiltyParty);
const dPhone = partyPhone(damagedParty);
if (!gPhone || !dPhone) return null;
const description = `با توافق طرفین مقصر و زیان دیده مشخص شد. کاربر ${gPhone} مقصر و کاربر ${dPhone} زیان دیده می باشد.`;
return {
guiltyPartyId: guiltyId,
description,
decidedAt: new Date(),
fields: MUTUAL_AGREEMENT_EXPERT_DECISION_FIELDS,
};
}
/**
* Merge complementary statement flags onto the second party for expert/claim UIs when
* only the first party submitted a formal confession (V2 remote flow).
*/
export function enrichBlamePartiesForAgreementView(
doc: Record<string, unknown>,
): Array<Record<string, unknown>> {
const parties = (doc.parties ?? []) as Array<Record<string, unknown>>;
if (!Array.isArray(parties) || parties.length === 0) return parties;
const first = parties.find((p) => p.role === PartyRole.FIRST) as
| {
statement?: Record<string, unknown>;
}
| undefined;
const secondIdx = parties.findIndex((p) => p.role === PartyRole.SECOND);
if (!first?.statement || secondIdx === -1) return parties;
if (doc.blameStatus !== BlameStatus.AGREED) return parties;
const st = first.statement as {
admitsGuilt?: boolean;
claimsDamage?: boolean;
acceptsExpertOpinion?: boolean;
};
if (st.acceptsExpertOpinion) return parties;
const admits = !!st.admitsGuilt;
const claims = !!st.claimsDamage;
if (admits === claims) return parties;
const second = parties[secondIdx] as {
statement?: Record<string, unknown>;
};
const prev = (second.statement ?? {}) as Record<string, unknown>;
const mergedStatement = {
...prev,
admitsGuilt: admits ? false : true,
claimsDamage: admits ? true : false,
acceptsExpertOpinion: false,
};
const next = [...parties];
next[secondIdx] = {
...second,
statement: mergedStatement,
};
return next;
}

View File

@@ -0,0 +1,105 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
export function resendRequestHasPayload(r: {
resendDescription?: string;
resendDocuments?: unknown[];
resendCarParts?: unknown[];
}): boolean {
if (!r) return false;
if (String(r.resendDescription || "").trim() !== "") return true;
if (Array.isArray(r.resendDocuments) && r.resendDocuments.length > 0) return true;
if (Array.isArray(r.resendCarParts) && r.resendCarParts.length > 0) return true;
return false;
}
/** Normalize expert-requested document keys (strings or { key }). */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
if (!Array.isArray(docs)) return [];
const keys: string[] = [];
for (const d of docs) {
if (typeof d === "string" && d.trim()) keys.push(d.trim());
else if (d && typeof d === "object" && "key" in (d as object)) {
const k = String((d as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
}
/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
if (!Array.isArray(parts)) return [];
const keys: string[] = [];
for (const p of parts) {
if (typeof p === "string" && p.trim()) keys.push(p.trim());
else if (p && typeof p === "object" && "key" in (p as object)) {
const k = String((p as { key?: string }).key || "").trim();
if (k) keys.push(k);
}
}
return keys;
}
function getRequiredDocEntry(claim: any, documentKey: string): unknown {
const rd = claim?.requiredDocuments;
if (!rd) return undefined;
return rd instanceof Map ? rd.get(documentKey) : rd[documentKey];
}
export function isRequiredDocumentUploaded(claim: any, documentKey: string): boolean {
const doc = getRequiredDocEntry(claim, documentKey) as { uploaded?: boolean } | undefined;
return !!doc?.uploaded;
}
export function hasDamagedPartCapture(claim: any, partKey: string): boolean {
const data = claim?.media?.damagedParts;
if (!data) return false;
const cap = data instanceof Map ? data.get(partKey) : data[partKey];
return !!cap;
}
/**
* True when the user has satisfied every document and part the damage expert asked for,
* or when the expert only left instructions (no doc/part list) and there is a description.
*/
export function canFinalizeExpertResend(claim: any): boolean {
if (!claim) return false;
if (claim.status !== ClaimCaseStatus.WAITING_FOR_USER_RESEND) return false;
if (claim.workflow?.currentStep !== ClaimWorkflowStep.USER_EXPERT_RESEND) return false;
const r = claim.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const docKeys = normalizeResendDocumentKeys(r.resendDocuments);
const partKeys = normalizeResendPartKeys(r.resendCarParts);
if (docKeys.length === 0 && partKeys.length === 0) {
return String(r.resendDescription || "").trim() !== "";
}
for (const k of docKeys) {
if (!isRequiredDocumentUploaded(claim, k)) return false;
}
for (const k of partKeys) {
if (!hasDamagedPartCapture(claim, k)) return false;
}
return true;
}
export function documentKeyAllowedForExpertResend(
claim: any,
documentKey: string,
): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
return allowed.has(documentKey);
}
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendPartKeys(r.resendCarParts));
return allowed.has(partKey);
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, ForbiddenException } from "@nestjs/common";
/** Insurance company tenant id on the actor JWT (Mongo ObjectId string). */
export function requireActorClientKey(actor: { clientKey?: string }): string {
const ck = actor?.clientKey;
if (!ck || typeof ck !== "string") {
throw new BadRequestException(
"Client scope is missing for this account. Insurer/expert users must be bound to an insurance company.",
);
}
return ck;
}
export function blameCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
return (doc?.parties ?? []).some(
(p: any) => String(p?.person?.clientId ?? "") === id,
);
}
export function claimCaseTouchesClient(doc: any, clientKey: string): boolean {
const id = String(clientKey);
const owner = doc?.owner as
| { clientId?: unknown; userClientKey?: unknown }
| undefined;
const tenantId = owner?.clientId ?? owner?.userClientKey;
return String(tenantId ?? "") === id;
}
/**
* Field expertinitiated 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.",
);
}
}

View File

@@ -1,401 +1,45 @@
import {
BadGatewayException,
Injectable,
Logger,
} from "@nestjs/common";
import axios from "axios";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { LookupDbService } from "./entities/db-service/lookup.db.service";
@Injectable()
export class LookupsService {
private readonly logger = new Logger(LookupsService.name);
private readonly GET_APP_TOKEN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/GetAppToken";
private readonly LOGIN_URL =
"https://insapm.ir/bimeapimanager/api/EITAuthentication/Login";
private readonly BASE_API_URL =
"https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/car";
private readonly BASE_INFO_PATH = "base-info";
private readonly CODE_LIST_PATH = "code-list";
// Authentication credentials
private readonly APP_NAME = "fanhab";
private readonly SECRET = "5Fa@N#A2B";
private readonly USERNAME = "fanhabUser";
private readonly PASSWORD = "Fan#@2U$3er";
// API headers
private readonly CORP_ID = "3539";
private readonly CONTRACT_ID = "263";
private readonly LOCATION = "100";
constructor(
private readonly lookupDbService: LookupDbService,
) {}
constructor(private readonly lookupDbService: LookupDbService) {}
/**
* Step 1: Get appToken from GetAppToken API
* The token is returned in the response header 'appToken'
* Returns stored `response` for a lookup document by `name` in the lookups collection.
*/
private async getAppToken(): Promise<string> {
try {
const requestHeaders: any = {
appname: this.APP_NAME,
secret: this.SECRET,
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
delete requestHeaders["Content-Type"];
const response = await axios({
method: "POST",
url: this.GET_APP_TOKEN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
async getLookup(lookupName: string): Promise<any> {
const doc = await this.lookupDbService.findOne({ name: lookupName });
if (!doc) {
this.logger.warn(`Lookup not found in database: ${lookupName}`);
throw new NotFoundException(`Lookup "${lookupName}" not found`);
}
return 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);
return doc.response;
}
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");
}
}
}
/**
* Step 2: Login to get authenticationToken
* The token is returned in the response body field 'authenticationtoken'
*/
private async login(appToken: string): Promise<string> {
try {
this.logger.log(`Attempting login with appToken: ${appToken}`);
this.logger.log(`Login URL: ${this.LOGIN_URL}`);
this.logger.log(`Username: ${this.USERNAME}`);
const requestHeaders: any = {
appToken: appToken,
userName: this.USERNAME,
password: this.PASSWORD,
"Content-Length": "0",
};
// Remove Content-Type header when Content-Length is 0
// Axios adds it automatically, but server doesn't expect it for empty body
delete requestHeaders["Content-Type"];
this.logger.log("Request headers (without password):", {
appToken: appToken,
userName: this.USERNAME,
password: "***",
"Content-Length": "0",
"Content-Type": "removed",
});
const response = await axios({
method: "POST",
url: this.LOGIN_URL,
data: "", // Empty string body
headers: requestHeaders,
transformRequest: [
(data, headers) => {
// Remove Content-Type header completely for empty body
if (headers) {
delete headers["Content-Type"];
delete headers["content-type"];
}
return data;
},
],
});
this.logger.log("Login response status:", response.status);
this.logger.log("Login response data:", JSON.stringify(response.data, null, 2));
this.logger.log("Login response headers:", JSON.stringify(response.headers, null, 2));
// Check response headers first (authenticationToken is in headers, not body)
const authenticationToken =
response.headers.authenticationtoken ||
response.headers.authenticationToken ||
response.headers["authenticationtoken"] ||
response.headers["authenticationToken"] ||
// Fallback to body if not in headers
response.data?.authenticationtoken ||
response.data?.authenticationToken ||
response.data?.authentication_token;
this.logger.log(`Extracted authenticationToken: ${authenticationToken ? 'Found' : 'Not found'}`);
if (!authenticationToken) {
this.logger.error("Failed to get authenticationToken from response");
this.logger.error("Response status:", response.status);
this.logger.error("Response headers:", JSON.stringify(response.headers, null, 2));
this.logger.error("Response headers keys:", Object.keys(response.headers || {}));
this.logger.error("Response data:", JSON.stringify(response.data, null, 2));
this.logger.error("Response data keys:", Object.keys(response.data || {}));
// Extract error message from response if available
const errorMessage = response.data?.Message ||
response.data?.message ||
"Failed to get authenticationToken from response";
throw new BadGatewayException(errorMessage);
}
this.logger.log(`Successfully obtained authenticationToken: ${authenticationToken}`);
return authenticationToken;
} catch (error) {
this.logger.error("Failed to login");
this.logger.error("AppToken used:", appToken);
this.logger.error("Login URL:", this.LOGIN_URL);
this.logger.error("Username:", this.USERNAME);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request method:", error.config?.method);
this.logger.error("Request headers:", JSON.stringify({
...error.config?.headers,
password: "***",
}, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
"Failed to login to external API";
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException("Failed to login to external API");
}
}
}
/**
* Step 3: Call the lookup API with authenticationToken
*/
private async callLookupApi(
lookupName: string,
authenticationToken: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
try {
const response = await axios.get(url, {
headers: {
authenticationToken: authenticationToken,
CorpId: this.CORP_ID,
ContractId: this.CONTRACT_ID,
Location: this.LOCATION,
"Content-Type": "application/json",
},
});
this.logger.log(`Successfully fetched data for lookup: ${lookupName}`);
return response.data;
} catch (error) {
this.logger.error(`Failed to fetch lookup data for ${lookupName}`);
this.logger.error("URL:", url);
this.logger.error("AuthenticationToken used:", authenticationToken);
if (axios.isAxiosError(error)) {
this.logger.error("Error status:", error.response?.status);
this.logger.error("Error status text:", error.response?.statusText);
this.logger.error("Error response data:", JSON.stringify(error.response?.data, null, 2));
this.logger.error("Error response headers:", JSON.stringify(error.response?.headers, null, 2));
this.logger.error("Request URL:", error.config?.url);
this.logger.error("Request headers:", JSON.stringify(error.config?.headers, null, 2));
// Extract error message from response
const errorMessage = error.response?.data?.Message ||
error.response?.data?.message ||
error.response?.data ||
error.message ||
`Failed to fetch lookup data for ${lookupName}`;
throw new BadGatewayException(errorMessage);
} else {
this.logger.error("Error details:", error);
throw new BadGatewayException(
`Failed to fetch lookup data for ${lookupName}`,
);
}
}
}
/**
* Complete authentication flow and fetch lookup data
*/
private async fetchLookupDataFromExternalApi(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<{ authenticationToken: string; response: any; url: string }> {
// Step 1: Get appToken
const appToken = await this.getAppToken();
// Step 2: Login to get authenticationToken
const authenticationToken = await this.login(appToken);
// Step 3: Call the lookup API
const url = `${this.BASE_API_URL}/${pathType}/${lookupName}`;
const response = await this.callLookupApi(
lookupName,
authenticationToken,
pathType,
);
return { authenticationToken, response, url };
}
/**
* Check if lookup data needs to be updated (once a month)
*/
private shouldUpdateLookup(updatedAt: Date | null): boolean {
if (!updatedAt) {
return true; // Never updated, need to fetch
}
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
return updatedAt < oneMonthAgo;
}
/**
* Get lookup data by name
* Returns cached data if available and not expired, otherwise fetches from external API
*/
async getLookup(
lookupName: string,
pathType: "base-info" | "code-list" = "base-info",
): Promise<any> {
// Check if we have cached data
const existingLookup = await this.lookupDbService.findOne({
name: lookupName,
});
if (existingLookup && !this.shouldUpdateLookup(existingLookup.updatedAt)) {
this.logger.log(
`Returning cached data for lookup: ${lookupName} (last updated: ${existingLookup.updatedAt})`,
);
return existingLookup.response;
}
// Need to fetch from external API
this.logger.log(`Fetching fresh data for lookup: ${lookupName}`);
const { authenticationToken, response, url } =
await this.fetchLookupDataFromExternalApi(lookupName, pathType);
// Save or update in database
await this.lookupDbService.findOneAndUpdate(
{ name: lookupName },
{
url: url,
authenticationToken: authenticationToken,
name: lookupName,
response: response,
updatedAt: new Date(),
},
);
this.logger.log(`Successfully saved/updated lookup data for: ${lookupName}`);
return response;
}
/**
* Get accident-causes lookup data
*/
async getAccidentCauses(): Promise<any> {
return await this.getLookup("accident-causes", "base-info");
return await this.getLookup("accident-causes");
}
/**
* Get accident-report-type lookup data
*/
async getAccidentReportType(): Promise<any> {
return await this.getLookup("accident-report-type", "code-list");
return await this.getLookup("accident-report-type");
}
/**
* Get vehicle-use-types lookup data
*/
async getVehicleUseTypes(): Promise<any> {
return await this.getLookup("vehicle-use-types", "base-info");
return await this.getLookup("vehicle-use-types");
}
/**
* Get dmg-pay-method lookup data
*/
async getDmgPayMethod(): Promise<any> {
return await this.getLookup("dmg-pay-method", "code-list");
return await this.getLookup("dmg-pay-method");
}
/**
* Get driving-licence-types lookup data
*/
async getDrivingLicenceTypes(): Promise<any> {
return await this.getLookup("driving-licence-types", "base-info");
return await this.getLookup("driving-licence-types");
}
/**
* Get accident-culprit-type lookup data
*/
async getAccidentCulpritType(): Promise<any> {
return await this.getLookup("accident-culprit-type", "code-list");
return await this.getLookup("accident-culprit-type");
}
}

View File

@@ -1,50 +1,28 @@
export class DamageExpertAllRequestsCountReportDtoRs {
all: number;
UnChecked: number;
CheckedRequest: number;
CheckAgain: number;
WaitingForUserToResend: number;
CloseRequest: number;
WaitingForUserCompleted: number;
InPersonVisit: number;
/**
* Native workflow status keys from blameCases (`CaseStatus`) or claimCases (`ClaimCaseStatus`),
* plus `all` = total matching documents for the tenant.
*/
export class BlameCaseStatusCountReportDtoRs {
[key: string]: number;
constructor(report: any) {
this.all = report.all;
this.UnChecked = report.UnChecked;
this.CheckedRequest = report.CheckedRequest;
this.CheckAgain = report.CheckAgain;
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
this.InPersonVisit = report.InPersonVisit;
constructor(report: Record<string, number>) {
Object.assign(this, report);
}
}
export class ExpertAllRequestsCountReportDtoRs {
all: number;
UnChecked: number;
CheckedRequest: number;
CheckAgain: number;
WaitingForUserToResend: number;
CloseRequest: number;
WaitingForUserCompleted: number;
export class ClaimCaseStatusCountReportDtoRs {
[key: string]: number;
constructor(report: any) {
this.all = report.all;
this.UnChecked = report.UnChecked;
this.CheckedRequest = report.CheckedRequest;
this.CheckAgain = report.CheckAgain;
this.WaitingForUserToResend = report.WaitingForUserToResend;
this.CloseRequest = report.CloseRequest;
this.WaitingForUserCompleted = report.WaitingForUserCompleted;
constructor(report: Record<string, number>) {
Object.assign(this, report);
}
}
export class CompanyAllRequestsCountReportDtoRs {
blame: object;
claim: object;
blame: Record<string, number>;
claim: Record<string, number>;
constructor(blame: object, claim: object) {
constructor(blame: Record<string, number>, claim: Record<string, number>) {
this.blame = blame;
this.claim = claim;
}

View File

@@ -1,7 +1,6 @@
import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { RequestManagementModule } from "src/request-management/request-management.module";
import { ClientModule } from "src/client/client.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@@ -9,7 +8,6 @@ import { ReportsService } from "./reports.service";
imports: [
RequestManagementModule,
ClaimRequestManagementModule,
ClientModule,
],
controllers: [ReportsController],
providers: [ReportsService],

View File

@@ -1,392 +1,231 @@
import { Injectable, Logger } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimRequestManagementDbService } from "src/claim-request-management/entites/db-service/claim-request-management.db.service";
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
import { ReqBlameStatus } from "src/Types&Enums/blame-request-management/status.enum";
import { ReqClaimStatus } from "src/Types&Enums/claim-request-management/status.enum";
import { UserType } from "src/Types&Enums/userType.enum";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { requireActorClientKey } from "src/helpers/tenant-scope";
import {
BlameCaseStatusCountReportDtoRs,
ClaimCaseStatusCountReportDtoRs,
CompanyAllRequestsCountReportDtoRs,
DamageExpertAllRequestsCountReportDtoRs,
ExpertAllRequestsCountReportDtoRs,
} from "./dto/reports.dto";
@Injectable()
export class ReportsService {
private readonly logger = new Logger(ReportsService.name);
constructor(
private readonly requestManagementDbService: RequestManagementDbService,
private readonly claimRequestManagementDbService: ClaimRequestManagementDbService,
private readonly clientDbService: ClientDbService,
private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
) {}
async getAllCheckedRequestsCountFn(role: string, client: string) {
const statuses = ["UnChecked", "CheckedRequest"];
const data: Record<string, number> = { all: 0 };
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;
private clientObjectId(client: string | undefined): Types.ObjectId {
const ck = requireActorClientKey({ clientKey: client });
return new Types.ObjectId(ck);
}
async getDateFilteredRequestByStatus(
role: string,
client: string,
start: Date,
end: Date,
) {
const statuses = ["UnChecked", "CheckedRequest", "CheckAgain"];
const data: Record<string, number> = {};
const clientId = new Types.ObjectId(client);
for (const status of statuses) {
let blameCount = 0;
let claimCount = 0;
if (role === "expert" || role === "company") {
blameCount = await this.requestManagementDbService.countByFilter({
blameStatus: status,
createdAt: { $gte: start, $lte: end },
$or: [
{ "firstPartyDetails.firstPartyClient.clientId": clientId },
{ "secondPartyDetails.secondPartyClient.clientId": clientId },
],
});
}
if (role === "damage_expert" || role === "company") {
claimCount = await this.claimRequestManagementDbService.countByFilter({
claimStatus: status,
userClientKey: clientId,
createdAt: { $gte: start, $lte: end },
});
}
data[status] = blameCount + claimCount;
}
return data;
}
private 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,
private isBlameForClient(r: any, client: Types.ObjectId): boolean {
const idStr = String(client);
return (r?.parties || []).some(
(p) => String(p?.person?.clientId || "") === idStr,
);
return !!matchingEntry;
}
/**
* Filters claim requests using the same logic as expert-claim service
* to ensure consistency between endpoints
*/
private async filterClaimRequestsForExpert(
requests: any[],
actor: any,
): Promise<any[]> {
const filteredRequests = [];
for (const r of requests) {
// For expert-initiated blame files, only show to the initiating expert
if (r.blameFile?.expertInitiated && r.blameFile?.initiatedBy) {
if (String(r.blameFile.initiatedBy) !== actor.sub) {
continue; // Skip if not the initiating expert
}
// Expert-initiated claim files are always visible to the initiating expert
filteredRequests.push(r);
continue;
private isClaimForClient(r: any, client: Types.ObjectId): boolean {
return String(r?.owner?.userClientKey || "") === String(client);
}
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;
private countBlameByCaseStatus(
blames: any[],
clientId: Types.ObjectId,
): Record<string, number> {
const out: Record<string, number> = { all: 0 };
for (const s of Object.values(CaseStatus)) {
out[s] = 0;
}
for (const r of blames) {
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;
}
const specialHandlingStatuses = [
ReqClaimStatus.CheckAgain,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.PendingFactorValidation,
];
const requiresSpecificActorCheck = specialHandlingStatuses.includes(
r.claimStatus,
);
if (requiresSpecificActorCheck) {
if (this.wasHandledByActor(r, actor.sub)) {
filteredRequests.push(r);
}
} else {
if (this.isVisibleToClientType(client, actor)) {
filteredRequests.push(r);
private 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;
}
return filteredRequests;
}
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 }),
]);
async getAllRequestsCountByRole(role: string, client: string, actor?: any) {
if (role === "expert") {
const statuses = Object.values(ReqBlameStatus);
const data: Record<string, number> = { all: 0 };
for (const status of statuses) {
const filter = {
blameStatus: status,
$or: [
{
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
client,
),
},
// {
// "secondPartyDetails.secondPartyClient.clientId":
// new Types.ObjectId(client),
// },
],
};
const count =
await this.requestManagementDbService.countByFilter(filter);
data[status] = count;
data.all += count;
}
return data;
return this.countBlameByCaseStatus(blames as any[], clientId);
}
if (role === "damage_expert") {
const statuses = Object.values(ReqClaimStatus);
const data: Record<string, number> = { all: 0 };
// For damage_expert, we need to apply the same filtering as expert-claim service
if (actor) {
// Fetch all requests with the statuses that expert-claim service shows
// This matches the statuses in getClaimRequestsListForExpert
const relevantStatuses = [
ReqClaimStatus.UnChecked,
ReqClaimStatus.ReviewRequest,
ReqClaimStatus.CheckAgain,
ReqClaimStatus.CloseRequest,
ReqClaimStatus.InPersonVisit,
ReqClaimStatus.CheckedRequest,
ReqClaimStatus.PendingFactorValidation,
];
// Fetch all requests with relevant statuses (matching expert-claim query)
const allRequests =
await this.claimRequestManagementDbService.findAllByStatus({
claimStatus: { $in: relevantStatuses },
});
// Filter requests using the same logic as expert-claim service
const filteredRequests =
await this.filterClaimRequestsForExpert(allRequests, actor);
// Count by status from filtered results
for (const status of statuses) {
const count = filteredRequests.filter(
(r) => r.claimStatus === status,
).length;
data[status] = count;
data.all += count;
}
} else {
// Fallback to simple count if actor not provided (shouldn't happen for damage_expert)
for (const status of statuses) {
const filter = {
claimStatus: status,
userClientKey: new Types.ObjectId(client),
};
const count =
await this.claimRequestManagementDbService.countByFilter(filter);
data[status] = count;
data.all += count;
}
return this.countClaimByCaseStatus(claims as any[], clientId);
}
return data;
}
// ✅ Company logic
if (role === "company") {
const blameStatuses = Object.values(ReqBlameStatus);
const claimStatuses = Object.values(ReqClaimStatus);
const blameData: Record<string, number> = { all: 0 };
const claimData: Record<string, number> = { all: 0 };
// Only count files where this company is FIRST party in Blame
for (const status of blameStatuses) {
const filter = {
blameStatus: status,
"firstPartyDetails.firstPartyClient.clientId": new Types.ObjectId(
client,
),
return {
blame: this.countBlameByCaseStatus(blames as any[], clientId),
claim: this.countClaimByCaseStatus(claims as any[], clientId),
};
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;
}
async getAllRequestsReportCount(actor, client) {
if (actor.role === "damage_expert") {
// Pass actor to apply filtering logic for damage_expert
const data = await this.getAllRequestsCountByRole(
actor.role,
client,
actor,
);
return new DamageExpertAllRequestsCountReportDtoRs(data);
} else if (actor.role === "expert") {
const data = await this.getAllRequestsCountByRole(actor.role, client);
return new ExpertAllRequestsCountReportDtoRs(data);
} else {
// company
const damageExpertData = await this.getAllRequestsCountByRole(
"damage_expert",
client,
);
const expertData = await this.getAllRequestsCountByRole("expert", client);
return new CompanyAllRequestsCountReportDtoRs(
expertData,
damageExpertData,
);
/**
* Per calendar month, counts by native `CaseStatus` / `ClaimCaseStatus`.
* Company gets both blame and claim maps; expert / damage_expert get one map.
*/
async getDateFilteredRequestByStatus(
role: string,
client: string | undefined,
start: Date,
end: Date,
) {
const clientId = this.clientObjectId(client);
const [blames, claims] = await Promise.all([
this.blameRequestDbService.find({}, { lean: true }),
this.claimCaseDbService.find({}, { lean: true }),
]);
const inRange = (r: any) => {
const createdAt = new Date(r.createdAt);
return createdAt >= start && createdAt <= end;
};
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 };
}
async getAllRequestsReportPerMonth(actor, client) {
const result = [];
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 getAllRequestsReportCount(actor: any, client: string | undefined) {
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();
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",
});
let data: any;
if (actor.role === "company") {
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const blameData = await this.getDateFilteredRequestByStatus(
"expert",
data = await this.getDateFilteredRequestByStatus(
"company",
client,
monthStart,
monthEnd,
);
const claimData = await this.getDateFilteredRequestByStatus(
"damage_expert",
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(
data = await this.getDateFilteredRequestByStatus(
actor.role,
client,
monthStart,
monthEnd,
);
}
result.unshift({
stDate: monthStart,
enDate: monthEnd,
@@ -396,18 +235,9 @@ export class ReportsService {
}
return result;
}
}
async getAllCheckedRequestsCount(actor, client) {
if (actor.role === "damage_expert" || actor.role === "expert") {
return this.getAllCheckedRequestsCountFn(actor.role, client);
} else {
const blame = await this.getAllCheckedRequestsCountFn("expert", client);
const claim = await this.getAllCheckedRequestsCountFn(
"damage_expert",
client,
);
return { blame, claim };
}
/** Same aggregates as `GET /reports/report/requests` (native status keys). */
async getAllCheckedRequestsCount(actor: any, client: string | undefined) {
return this.getAllRequestsReportCount(actor, client);
}
}

View File

@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
/**
* Computed on GET blame v2 for the current viewer — no DB field on BlameRequest.
*/
export class ClaimCreationHintDto {
@ApiProperty({
description:
"True if a v2 claim case already exists for this blame (by blameRequestId).",
})
hasClaim: boolean;
@ApiProperty({
description:
"True when this viewer is the damaged party, blame is COMPLETED, no claim exists yet, " +
"and for THIRD_PARTY all parties have signed and accepted the expert decision.",
})
shouldGuideToCreateClaim: boolean;
}

View File

@@ -48,44 +48,74 @@ export class FirstPartyFileDto {
}
export class DescriptionDto {
@ApiProperty()
@ApiProperty({ description: "Accident description (required for all types)" })
desc: string;
// CAR_BODY specific fields
@ApiPropertyOptional({
description: "Date of accident (for CAR_BODY type only)",
example: "2025-12-08"
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "2025-12-08",
})
accidentDate?: Date;
@ApiPropertyOptional({
description: "Time of accident (for CAR_BODY type only)",
example: "14:30"
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "14:30",
})
accidentTime?: string;
@ApiPropertyOptional({
enum: WeatherCondition,
description: "Weather condition at time of accident (for CAR_BODY type only)",
example: WeatherCondition.CLEAR
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: WeatherCondition.CLEAR,
})
weatherCondition?: WeatherCondition;
@ApiPropertyOptional({
enum: RoadCondition,
description: "Road condition at time of accident (for CAR_BODY type only)",
example: RoadCondition.DRY
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: RoadCondition.DRY,
})
roadCondition?: RoadCondition;
@ApiPropertyOptional({
enum: LightCondition,
description: "Light condition at time of accident (for CAR_BODY type only)",
example: LightCondition.DAYLIGHT
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: LightCondition.DAYLIGHT,
})
lightCondition?: LightCondition;
}
/**
* THIRD_PARTY description step: only desc is accepted.
*/
export class ThirdPartyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
}
/**
* CAR_BODY description step: desc + accident date/time and conditions required.
*/
export class CarBodyDescriptionDto {
@ApiProperty({ description: "Accident description" })
desc: string;
@ApiProperty({ description: "Date of accident", example: "2025-12-08" })
accidentDate: Date;
@ApiProperty({ description: "Time of accident", example: "14:30" })
accidentTime: string;
@ApiProperty({ enum: WeatherCondition, example: WeatherCondition.CLEAR })
weatherCondition: WeatherCondition;
@ApiProperty({ enum: RoadCondition, example: RoadCondition.DRY })
roadCondition: RoadCondition;
@ApiProperty({ enum: LightCondition, example: LightCondition.DAYLIGHT })
lightCondition: LightCondition;
}
export class FirstPartyDetail {
// @ApiProperty({ type: String })
firstPartyId?: Types.ObjectId;

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto, CarBodyFormDto } from "./create-request-management.dto";
@@ -24,9 +23,6 @@ export class ExpertCompleteCarBodyFormDto {
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto;
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { LocationDto } from "./create-request-management.dto";
import { DescriptionDto } from "./create-request-management.dto";
import { InitialFormDto } from "./create-request-management.dto";
@@ -20,9 +19,6 @@ export class ExpertSecondPartyInfoDto {
@ApiProperty({ type: AddPlateDto })
plate: AddPlateDto;
@ApiProperty({ type: LocationDto })
location: LocationDto;
@ApiProperty({ type: DescriptionDto })
description: DescriptionDto;
}
@@ -44,9 +40,6 @@ export class ExpertCompleteThirdPartyFormDto {
@ApiProperty({ type: AddPlateDto })
firstPartyPlate: AddPlateDto;
@ApiProperty({ type: LocationDto })
firstPartyLocation: LocationDto;
@ApiProperty({ type: DescriptionDto })
firstPartyDescription: DescriptionDto;

View File

@@ -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;
}

View File

@@ -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;
}

View 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";
}

View 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;
}

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
export class UserResendResponseDto {
@ApiProperty({
@@ -24,6 +24,22 @@ export class UserResendResponseDto {
description: "Whether the user has completed uploading all requested items",
})
completed: boolean;
@ApiPropertyOptional({
description:
"Per-item UI hint: document_camera (open camera), voice, video, or text (multiline field).",
example: [
{ item: "drivingLicense", inputKind: "document_camera" },
{ item: "description", inputKind: "text" },
],
})
requestedItemsUi?: { item: string; inputKind: string }[];
@ApiPropertyOptional({
description: "Requested items this party has not yet satisfied (partial resend).",
example: ["carCertificate"],
})
remainingItems?: string[];
}
export class UserResendUploadResponseDto {

View 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;
}

View File

@@ -50,5 +50,10 @@ export class AccidentInfo {
@Prop({ type: AccidentClassificationSchema })
classification?: AccidentClassification;
/** CAR_BODY: weather / road / light at time of accident */
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
}
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);

View File

@@ -1,19 +1,27 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
import { HydratedDocument, Types } from "mongoose";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { Party, PartySchema } from "./partyRole.enum";
import { AccidentInfo, AccidentInfoSchema } from "./accidentInformation.type";
import { ExpertSection, ExpertSectionSchema } from "./expert-section.type";
import { Workflow, WorkflowSchema } from "./workflow.type";
import { HistoryEvent, HistoryEventSchema } from "./historyEvent.type";
import { CaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameStatus } from "src/Types&Enums/blame-request-management/blameStatus.enum";
import { CreationMethod, FilledBy } from "./request-management.schema";
/** CAR_BODY only: mocked insurance inquiry result (legacy top-level; prefer parties[].insurance.carBodyInsurance) */
@Schema({ _id: false })
export class CarBodyInsuranceDetail {
@Prop() policyNumber?: string;
@Prop() startDate?: string;
@Prop() endDate?: string;
@Prop() insurerCompany?: string;
@Prop({ type: [String] }) coverages?: string[];
}
export const CarBodyInsuranceDetailSchema = SchemaFactory.createForClass(CarBodyInsuranceDetail);
@Schema({ _id: false })
export class BlameRequestSnapshot {
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties?: Party[];
}
@@ -45,9 +53,6 @@ export class BlameRequest {
@Prop({ type: WorkflowSchema })
workflow?: Workflow;
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties: Party[];
@@ -57,12 +62,40 @@ export class BlameRequest {
@Prop({ type: [HistoryEventSchema], default: [] })
history: HistoryEvent[];
/** CAR_BODY only: first party car-body insurance (mocked inquiry) legacy; prefer parties[0].insurance.carBodyInsurance */
@Prop({ type: CarBodyInsuranceDetailSchema })
carBodyInsuranceDetail?: CarBodyInsuranceDetail;
/**
* Optional snapshot structure (kept for future use / read-optimized copies).
* Source of truth remains the top-level fields of this document.
*/
@Prop({ type: BlameRequestSnapshotSchema, required: false })
snapshot?: BlameRequestSnapshot;
/** True when this blame was created by a field expert (independent expert); only that expert can see/review it. */
@Prop({ default: false })
expertInitiated?: boolean;
/** Field expert who created this file (for expert-initiated flows). */
@Prop({ type: Types.ObjectId })
initiatedByFieldExpertId?: Types.ObjectId;
/** True when this blame was created by a registrar. */
@Prop({ default: false })
registrarInitiated?: boolean;
/** Registrar who created this file (for registrar-initiated flows). */
@Prop({ type: Types.ObjectId })
initiatedByRegistrarId?: Types.ObjectId;
/** LINK = expert sends link to user(s); IN_PERSON = expert fills form on-site. */
@Prop({ type: String, enum: CreationMethod })
creationMethod?: CreationMethod;
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
@Prop({ type: String, enum: FilledBy })
filledBy?: FilledBy;
}
export type BlameRequestDocument = HydratedDocument<BlameRequest>;

View File

@@ -64,6 +64,10 @@ export class PartyResendRequest {
@Prop({ type: [String], default: [] })
requestedItems: string[]; // Array of ResendItemType values
/** @deprecated Resend UI uses requestedItems + inputKind from API; do not rely on workflow steps. */
@Prop({ type: [String], default: [] })
workflowStepKeys?: string[];
@Prop()
description?: string;
@@ -75,6 +79,20 @@ export class PartyResendRequest {
@Prop({ type: Date })
completedAt?: Date;
/** Document resend uploads keyed by ResendItemType (e.g. drivingLicense). */
@Prop({ type: Object, default: {} })
uploadedDocuments?: Record<string, Types.ObjectId>;
@Prop({ type: Types.ObjectId })
resendVoiceId?: Types.ObjectId;
@Prop({ type: Types.ObjectId })
resendVideoId?: Types.ObjectId;
/** Fulfills requestedItems `description` (text). */
@Prop({ type: String })
userTextDescription?: string;
}
export const PartyResendRequestSchema = SchemaFactory.createForClass(PartyResendRequest);
@@ -89,6 +107,12 @@ export class ExpertResend {
@Prop({ type: 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);

View File

@@ -70,6 +70,16 @@ export class Insurance {
@Prop() financialCeiling?: string;
@Prop({ type: [String] })
coverages?: string[];
/** CAR_BODY only: mocked car-body insurance inquiry result */
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyInsurance?: {
policyNumber?: string;
startDate?: string;
endDate?: string;
insurerCompany?: string;
coverages?: string[];
};
}
export const InsuranceSchema = SchemaFactory.createForClass(Insurance);
@@ -87,6 +97,13 @@ export class PartyStatement {
@Prop({ type: String })
description?: string;
/** CAR_BODY: accident conditions from description step */
@Prop() accidentDate?: Date;
@Prop() accidentTime?: string;
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
}
export const PartyStatementSchema = SchemaFactory.createForClass(PartyStatement);
@@ -130,6 +147,13 @@ export class Party {
@Prop({ type: PersonSchema })
person: Person;
/**
* CAR_BODY only: first form accident with car vs object.
* Second form (guilty/damaged) may be added later as carBodySecondForm.
*/
@Prop({ type: MongooseSchema.Types.Mixed })
carBodyFirstForm?: { car?: boolean; object?: boolean };
/**
* Party-submitted location (step-driven: FIRST_LOCATION / SECOND_LOCATION).
*/

View File

@@ -282,6 +282,7 @@ export enum CreationMethod {
export enum FilledBy {
CUSTOMER = "customer",
EXPERT = "expert",
REGISTRAR = "registrar",
}
export class ExpertLinkInfo {

View 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,
);
}
}

View 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!,
);
}
}

View File

@@ -12,7 +12,9 @@ import { UsersModule } from "src/users/users.module";
import { CronModule } from "src/utils/cron/cron.module";
import { SmsManagerModule } from "src/utils/sms-manager/sms-manager.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 { AuthModule } from "src/auth/auth.module";
import { BlameDocumentDbService } from "./entities/db-service/blame-document.db.service";
import { BlameVoiceDbService } from "./entities/db-service/blame.voice.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 { RequestManagementV2Controller } from "./request-management.v2.controller";
import { ExpertInitiatedController } from "./expert-initiated.controller";
import { ExpertInitiatedV2Controller } from "./expert-initiated.v2.controller";
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
@Module({
imports: [
@@ -43,6 +47,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
SandHubModule,
SmsManagerModule,
PublicIdModule,
HashModule,
WorkflowStepManagementModule,
PlatesModule,
MulterModule.register({
@@ -58,11 +63,14 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
]),
forwardRef(() => ClaimRequestManagementModule),
CronModule,
AuthModule,
],
controllers: [
RequestManagementController,
RequestManagementV2Controller,
ExpertInitiatedController,
ExpertInitiatedV2Controller,
RegistrarInitiatedController,
],
providers: [
RequestManagementService,

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
@@ -33,6 +34,7 @@ import {
CreateBlameRequestDtoV2,
DescriptionDto,
LocationDto,
CarBodyFormDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
@@ -64,6 +66,32 @@ 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")
@ApiOperation({
summary: "Get one blame request (v2)",
description:
"Response is the blame document plus **claimCreation** ({ hasClaim, shouldGuideToCreateClaim }) — computed for the current user so the app can route the damaged party to create-claim when the blame is COMPLETED and no v2 claim exists yet.",
})
@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")
@ApiParam({ name: "requestId" })
@ApiBody({ type: BlameConfessionDtoV2 })
@@ -76,6 +104,19 @@ export class RequestManagementV2Controller {
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({
schema: {
type: "object",
@@ -185,7 +226,28 @@ export class RequestManagementV2Controller {
@Post("/add-detail-description/: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)
async addDescriptionV2(
@Param("requestId") requestId: string,
@@ -331,6 +393,11 @@ export class RequestManagementV2Controller {
format: "binary",
description: "Video file (if requested)",
},
description: {
type: "string",
description:
"Text description when expert requested ResendItemType.description",
},
},
},
})
@@ -371,11 +438,13 @@ export class RequestManagementV2Controller {
voice?: Express.Multer.File[];
video?: Express.Multer.File[];
},
@Body("description") description?: string,
) {
return this.requestManagementService.uploadResendDocumentsV2(
requestId,
user.sub,
files,
description,
);
}
}

View File

@@ -29,7 +29,144 @@ export class SandHubService {
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> {
if (!this.useLiveSandHubApis()) {
return "mock-sandhub-access-token";
}
if (this.loginToken && this.tokenExpiry && this.tokenExpiry > new Date()) {
return this.loginToken;
}
@@ -67,6 +204,9 @@ export class SandHubService {
}
private async getTejaratAccessToken(): Promise<string> {
if (!this.useLiveSandHubApis()) {
return "mock-tejarat-access-token";
}
if (
this.tejaratAccessToken &&
this.tejaratTokenExpiry &&
@@ -122,6 +262,10 @@ export class SandHubService {
}
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 BACKOFF_FACTOR = 2;
@@ -183,7 +327,14 @@ export class SandHubService {
};
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);
return { raw, mapped };
}
@@ -193,6 +344,10 @@ export class SandHubService {
}
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 INITIAL_DELAY = 1000;
const BACKOFF_FACTOR = 2;
@@ -293,16 +448,25 @@ export class SandHubService {
rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer,
};
const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`;
const response = await this.makeSandHubRequest(requestUrl, requestPayload);
const base = process.env.SANDHUB_BASE_URL ?? "";
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") {
throw new Error("خودرو شما شخصی / سواری نمی باشد")
let response: any;
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;
} catch (err) {
throw new Error(err);

View 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) });
}
}

View 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();
});

View File

@@ -7,6 +7,7 @@ import { OtpModule } from "src/utils/otp/otp.module";
import { ExpertDbService } from "./entities/db-service/expert.db.service";
import { FieldExpertDbService } from "./entities/db-service/field-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 {
DamageExpertDbSchema,
@@ -21,6 +22,10 @@ import {
InsurerExpertDbSchema,
InsurerExpertModel,
} from "./entities/schema/insurer-expert.schema";
import {
RegistrarDbSchema,
RegistrarModel,
} from "./entities/schema/registrar.schema";
import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
@Module({
@@ -31,6 +36,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
{ name: ExpertModel.name, schema: ExpertDbSchema },
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema },
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
]),
OtpModule,
HashModule,
@@ -42,6 +48,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService,
FieldExpertDbService,
InsurerExpertDbService,
RegistrarDbService,
],
exports: [
UserDbService,
@@ -49,6 +56,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService,
FieldExpertDbService,
InsurerExpertDbService,
RegistrarDbService,
],
})
export class UsersModule {}

View 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;
}

View File

@@ -1,5 +1,16 @@
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 {
message: string;
@@ -15,16 +26,84 @@ export interface VerifyLookUpMessage {
@Injectable()
export class SmsManagerService {
private readonly logger = new Logger(SmsManagerService.name);
constructor(private readonly sender: KavenegarService) {}
async sendMessage(data: SendMessage) {
return await this.sender.Send({
try {
const body = await this.sender.Send({
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) {
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}`,
);
}
}

View 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);
}

View File

@@ -102,7 +102,7 @@ export const createClaimWorkflowStepExamples = {
description_en: 'Select other damaged parts (non-body), Sheba number and insurer national code',
category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OUTER_PARTS'],
nextPossibleSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
nextPossibleSteps: ['CAPTURE_PART_DAMAGES'],
allowedRoles: ['user'],
estimatedDuration: 4,
fields: [
@@ -188,15 +188,15 @@ export const createClaimWorkflowStepExamples = {
value: {
stepKey: 'UPLOAD_REQUIRED_DOCUMENTS',
type: 'CLAIM',
stepNumber: 4,
stepNumber: 5,
isActive: true,
stepName_fa: 'آپلود مدارک مورد نیاز',
stepName_en: 'Upload Required Documents',
description_fa: 'آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)',
description_en: 'Upload required documents for claim review (dynamic list based on selected parts)',
category: 'CLAIM',
requiredPreviousSteps: ['SELECT_OTHER_PARTS'],
nextPossibleSteps: ['CAPTURE_PART_DAMAGES'],
requiredPreviousSteps: ['CAPTURE_PART_DAMAGES'],
nextPossibleSteps: ['USER_SUBMISSION_COMPLETE'],
allowedRoles: ['user'],
estimatedDuration: 5,
fields: [],
@@ -215,15 +215,15 @@ export const createClaimWorkflowStepExamples = {
value: {
stepKey: 'CAPTURE_PART_DAMAGES',
type: 'CLAIM',
stepNumber: 5,
stepNumber: 4,
isActive: true,
stepName_fa: 'ثبت تصاویر آسیب‌های قطعات',
stepName_en: 'Capture Part Damages',
description_fa: 'ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)',
description_en: 'Capture damage images for each part (one request per part)',
category: 'CLAIM',
requiredPreviousSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
nextPossibleSteps: ['USER_SUBMISSION_COMPLETE'],
requiredPreviousSteps: ['SELECT_OTHER_PARTS'],
nextPossibleSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
allowedRoles: ['user'],
estimatedDuration: 10,
fields: [],
@@ -251,7 +251,7 @@ export const createClaimWorkflowStepExamples = {
description_fa: 'اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت',
description_en: 'User information completed, waiting for damage expert review',
category: 'CLAIM',
requiredPreviousSteps: ['CAPTURE_PART_DAMAGES'],
requiredPreviousSteps: ['UPLOAD_REQUIRED_DOCUMENTS'],
nextPossibleSteps: ['EXPERT_DAMAGE_ASSESSMENT'],
allowedRoles: ['user', 'damage_expert', 'admin'],
estimatedDuration: 0,

View File

@@ -128,9 +128,9 @@ export class CreateWorkflowStepDto {
stepKey: WorkflowStep | ClaimWorkflowStep;
@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',
enum: ['THIRD_PARTY', 'CLAIM']
enum: ['THIRD_PARTY', 'CAR_BODY', 'CLAIM']
})
@IsNotEmpty()
@IsString()

View File

@@ -1158,7 +1158,7 @@ export const claimWorkflowStepExamples = {
"description_en": "Select other damaged parts (non-body), Sheba number and insurer national code",
"category": "CLAIM",
"requiredPreviousSteps": ["SELECT_OUTER_PARTS"],
"nextPossibleSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"nextPossibleSteps": ["CAPTURE_PART_DAMAGES"],
"allowedRoles": ["user"],
"estimatedDuration": 4,
"fields": [
@@ -1240,19 +1240,19 @@ export const claimWorkflowStepExamples = {
},
uploadRequiredDocuments: {
summary: 'Claim Step 4: UPLOAD_REQUIRED_DOCUMENTS',
summary: 'Claim Step 5: UPLOAD_REQUIRED_DOCUMENTS',
value: {
"stepKey": "UPLOAD_REQUIRED_DOCUMENTS",
"type": "CLAIM",
"stepNumber": 4,
"stepNumber": 5,
"isActive": true,
"stepName_fa": "آپلود مدارک مورد نیاز",
"stepName_en": "Upload Required Documents",
"description_fa": "آپلود مدارک مورد نیاز برای بررسی خسارت (لیست داینامیک بر اساس قطعات انتخاب شده)",
"description_en": "Upload required documents for claim review (dynamic list based on selected parts)",
"category": "CLAIM",
"requiredPreviousSteps": ["SELECT_OTHER_PARTS"],
"nextPossibleSteps": ["CAPTURE_PART_DAMAGES"],
"requiredPreviousSteps": ["CAPTURE_PART_DAMAGES"],
"nextPossibleSteps": ["USER_SUBMISSION_COMPLETE"],
"allowedRoles": ["user"],
"estimatedDuration": 5,
"fields": [],
@@ -1266,19 +1266,19 @@ export const claimWorkflowStepExamples = {
},
capturePartDamages: {
summary: 'Claim Step 5: CAPTURE_PART_DAMAGES',
summary: 'Claim Step 4: CAPTURE_PART_DAMAGES',
value: {
"stepKey": "CAPTURE_PART_DAMAGES",
"type": "CLAIM",
"stepNumber": 5,
"stepNumber": 4,
"isActive": true,
"stepName_fa": "ثبت تصاویر آسیب‌های قطعات",
"stepName_en": "Capture Part Damages",
"description_fa": "ثبت تصاویر آسیب برای هر قطعه (یک درخواست به ازای هر قطعه)",
"description_en": "Capture damage images for each part (one request per part)",
"category": "CLAIM",
"requiredPreviousSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"nextPossibleSteps": ["USER_SUBMISSION_COMPLETE"],
"requiredPreviousSteps": ["SELECT_OTHER_PARTS"],
"nextPossibleSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"allowedRoles": ["user"],
"estimatedDuration": 10,
"fields": [],
@@ -1305,7 +1305,7 @@ export const claimWorkflowStepExamples = {
"description_fa": "اطلاعات کاربر به طور کامل ثبت شد و در انتظار بررسی کارشناس خسارت",
"description_en": "User information completed, waiting for damage expert review",
"category": "CLAIM",
"requiredPreviousSteps": ["CAPTURE_PART_DAMAGES"],
"requiredPreviousSteps": ["UPLOAD_REQUIRED_DOCUMENTS"],
"nextPossibleSteps": ["EXPERT_DAMAGE_ASSESSMENT"],
"allowedRoles": ["user", "damage_expert", "admin"],
"estimatedDuration": 0,
@@ -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;

View File

@@ -1123,6 +1123,43 @@ export class WorkflowStepManagementService {
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...
];