Compare commits

...

26 Commits

Author SHA1 Message Date
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
46 changed files with 5587 additions and 1760 deletions

2848
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

@@ -2,7 +2,10 @@
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

@@ -1,19 +1,21 @@
//! NEW
export enum CaseStatus {
OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED"
}
OPEN = "OPEN",
WAITING_FOR_SECOND_PARTY = "WAITING_FOR_SECOND_PARTY",
WAITING_FOR_EXPERT = "WAITING_FOR_EXPERT",
WAITING_FOR_DOCUMENT_RESEND = "WAITING_FOR_DOCUMENT_RESEND",
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
AUTO_CLOSED = "AUTO_CLOSED",
STOPPED = "STOPPED"
}

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} `,
);
})
.catch((er) => {
this.logger.error(
`${"phone : " + mobile + " " + ", status : " + er["return"].status + ", otp : " + otp} `,
);
throw new HttpException(
" auth sms send failed",
HttpStatus.INTERNAL_SERVER_ERROR,
);
});
this.logger.log(
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
);
} catch (err) {
this.logger.error(
`Auth OTP SMS failed phone=${mobile} otp=${otp} ${describeSmsError(err)}`,
);
if (err instanceof HttpException) {
throw err;
}
throw new HttpException(
"auth sms send failed",
HttpStatus.BAD_GATEWAY,
);
}
}
}

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

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

@@ -49,6 +49,7 @@ import { ClaimRequiredDocumentType, CarAngle } from "src/Types&Enums/claim-reque
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
@@ -556,11 +557,10 @@ export class ClaimRequestManagementService {
const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
if (isCarBody) {
// Check if accident is with another object (not a car)
// This can be determined by:
// 1. carBodyForm.carBodyForm.car === false (explicitly set to false)
// 2. OR no secondPartyCarDetail exists (no second party car)
const isAccidentWithOtherObject =
// Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
const isAccidentWithOtherObject =
(firstPartyCarBody && firstPartyCarBody.object === true) ||
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail;
@@ -2734,44 +2734,68 @@ export class ClaimRequestManagementService {
);
}
// 3. Validate expert decision exists
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
// 4. Validate both parties have signed and accepted
const parties = blameRequest.parties || [];
if (parties.length < 2) {
throw new BadRequestException(
"Both parties must be present in the blame request",
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
if (isCarBody) {
// CAR_BODY: single party, no expert; first party is the damaged one who creates the claim
if (parties.length < 1) {
throw new BadRequestException("Blame request has no party");
}
const firstParty = parties.find((p) => p.role === "FIRST");
if (!firstParty || String(firstParty.person?.userId) !== currentUserId) {
throw new ForbiddenException(
"Only the first party (damaged) can create a claim from this CAR_BODY blame request",
);
}
} else {
// THIRD_PARTY: require expert decision and both parties signed
if (!blameRequest.expert?.decision) {
throw new BadRequestException(
"Cannot create claim until expert has made a decision",
);
}
const guiltyPartyId = String(blameRequest.expert.decision.guiltyPartyId);
if (parties.length < 2) {
throw new BadRequestException(
"Both parties must be present in the blame request",
);
}
const allPartiesSigned = parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
const guiltyParty = parties.find((p) => {
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
}
const allPartiesSigned = parties.every(
(p) => p.confirmation !== undefined && p.confirmation !== null,
);
if (!allPartiesSigned) {
throw new BadRequestException(
"Both parties must sign the expert decision before creating a claim",
);
}
const allPartiesAccepted = parties.every(
(p) => p.confirmation?.accepted === true,
);
if (!allPartiesAccepted) {
throw new BadRequestException(
"Both parties must accept the expert decision. If rejected, in-person resolution is required.",
);
}
// 5. Validate current user is the DAMAGED party (NOT the guilty party)
// 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
const currentUserParty = parties.find(
(p) => String(p.person?.userId) === currentUserId,
);
@@ -2782,24 +2806,6 @@ export class ClaimRequestManagementService {
);
}
const currentUserIsGuilty = parties.some(
(p) =>
String(p.person?.userId) === currentUserId &&
String((p as any)._id || p.person?.userId) === guiltyPartyId,
);
// Better check: compare guiltyPartyId with person.userId of each party
const guiltyParty = parties.find((p) => {
// guiltyPartyId could be userId or party identifier
return String(p.person?.userId) === guiltyPartyId;
});
if (guiltyParty && String(guiltyParty.person?.userId) === currentUserId) {
throw new ForbiddenException(
"You cannot create a claim as you are the guilty party",
);
}
// 6. Validate no existing claim for this blame
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
@@ -2874,6 +2880,246 @@ export class ClaimRequestManagementService {
}
}
/**
* V2: Field expert creates a claim from an expert-initiated IN_PERSON completed blame.
* The claim owner is set to the damaged party (first for CAR_BODY, non-guilty for THIRD_PARTY).
*/
async createClaimFromBlameForExpertV2(
blameRequestId: string,
expert: { sub: string; firstName?: string; lastName?: string },
): Promise<CreateClaimFromBlameResponseDto> {
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
if (!blameRequest) {
throw new NotFoundException("Blame request not found");
}
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
);
}
if (
!blameRequest.expertInitiated ||
blameRequest.creationMethod !== "IN_PERSON" ||
!blameRequest.initiatedByFieldExpertId
) {
throw new BadRequestException(
"This endpoint is only for expert-initiated IN_PERSON blame files.",
);
}
if (String(blameRequest.initiatedByFieldExpertId) !== String(expert.sub)) {
throw new ForbiddenException(
"Only the field expert who created this blame file can create the claim.",
);
}
const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
let damagedUserId: string;
if (isCarBody) {
if (parties.length < 1) throw new BadRequestException("Blame request has no party");
const first = parties.find((p) => p.role === "FIRST");
if (!first?.person?.userId) throw new BadRequestException("First party has no userId");
damagedUserId = String(first.person.userId);
} else {
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
? String(blameRequest.expert.decision.guiltyPartyId)
: null;
if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set");
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
);
if (!damagedParty?.person?.userId) {
throw new BadRequestException("Could not determine damaged party");
}
damagedUserId = String(damagedParty.person.userId);
}
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
});
if (existingClaim) {
throw new ConflictException("A claim for this blame case already exists");
}
const claimNo = await this.generateUniqueClaimNumber();
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
);
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING,
workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
owner: {
userId: new Types.ObjectId(damagedUserId),
userRole: damagedParty?.role as any,
},
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert",
},
timestamp: new Date(),
metadata: {
blameRequestId,
blamePublicId: blameRequest.publicId,
createdByExpert: true,
},
},
],
} as any);
this.logger.log(
`Claim created by field expert: ${newClaim._id} from blame: ${blameRequestId}`,
);
return {
claimRequestId: String(newClaim._id),
publicId: blameRequest.publicId,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
message: "Claim created successfully. You can now fill claim data on behalf of the damaged party.",
};
}
/**
* Resolve effective user id for claim operations.
* For FIELD_EXPERT acting on expert-initiated IN_PERSON claim, returns the claim owner's id; otherwise returns currentUserId.
*/
private async resolveClaimEffectiveUserId(
claimCase: any,
currentUserId: string,
actorRole?: string,
): Promise<string> {
if (
![RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR].includes(actorRole as any) ||
!claimCase?.blameRequestId
) {
return currentUserId;
}
const blameRequest = await this.blameRequestDbService.findById(
claimCase.blameRequestId.toString(),
);
if (!blameRequest || blameRequest.creationMethod !== "IN_PERSON") return currentUserId;
if (actorRole === RoleEnum.FIELD_EXPERT) {
if (
!blameRequest.expertInitiated ||
!blameRequest.initiatedByFieldExpertId ||
String(blameRequest.initiatedByFieldExpertId) !== currentUserId
) {
return currentUserId;
}
} else {
if (
!blameRequest.registrarInitiated ||
!blameRequest.initiatedByRegistrarId ||
String(blameRequest.initiatedByRegistrarId) !== currentUserId
) {
return currentUserId;
}
}
return claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId;
}
async createClaimFromBlameForRegistrarV1(
blameRequestId: string,
registrar: { sub: string; firstName?: string; lastName?: string },
): Promise<CreateClaimFromBlameResponseDto> {
const blameRequest = await this.blameRequestDbService.findById(blameRequestId);
if (!blameRequest) throw new NotFoundException("Blame request not found");
if (blameRequest.status !== BlameCaseStatus.COMPLETED) {
throw new BadRequestException(
`Blame request must be COMPLETED. Current status: ${blameRequest.status}`,
);
}
if (
!blameRequest.registrarInitiated ||
blameRequest.creationMethod !== "IN_PERSON" ||
!blameRequest.initiatedByRegistrarId
) {
throw new BadRequestException(
"This endpoint is only for registrar-initiated IN_PERSON blame files.",
);
}
if (String(blameRequest.initiatedByRegistrarId) !== String(registrar.sub)) {
throw new ForbiddenException(
"Only the registrar who created this blame file can create the claim.",
);
}
const parties = blameRequest.parties || [];
const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
let damagedUserId: string;
if (isCarBody) {
const first = parties.find((p) => p.role === "FIRST");
if (!first?.person?.userId) throw new BadRequestException("First party has no userId");
damagedUserId = String(first.person.userId);
} else {
const guiltyPartyId = blameRequest.expert?.decision?.guiltyPartyId
? String(blameRequest.expert.decision.guiltyPartyId)
: null;
if (!guiltyPartyId) throw new BadRequestException("Blame request has no guilty party set");
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) !== guiltyPartyId,
);
if (!damagedParty?.person?.userId) throw new BadRequestException("Could not determine damaged party");
damagedUserId = String(damagedParty.person.userId);
}
const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId),
});
if (existingClaim) throw new ConflictException("A claim for this blame case already exists");
const claimNo = await this.generateUniqueClaimNumber();
const damagedParty = parties.find(
(p) => p.person?.userId && String(p.person.userId) === damagedUserId,
);
const newClaim = await this.claimCaseDbService.create({
requestNo: claimNo,
publicId: blameRequest.publicId,
blameRequestId: new Types.ObjectId(blameRequestId),
blameRequestNo: blameRequest.requestNo,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
claimStatus: ClaimStatus.PENDING,
workflow: {
currentStep: ClaimWorkflowStep.SELECT_OUTER_PARTS,
nextStep: ClaimWorkflowStep.SELECT_OTHER_PARTS,
completedSteps: [ClaimWorkflowStep.CLAIM_CREATED],
locked: false,
},
owner: {
userId: new Types.ObjectId(damagedUserId),
userRole: damagedParty?.role as any,
},
createdByRegistrarId: new Types.ObjectId(registrar.sub),
history: [
{
type: "CLAIM_CREATED",
actor: {
actorId: new Types.ObjectId(registrar.sub),
actorName: `${registrar.firstName || ""} ${registrar.lastName || ""}`.trim(),
actorType: "registrar",
},
timestamp: new Date(),
metadata: {
blameRequestId,
blamePublicId: blameRequest.publicId,
createdByRegistrar: true,
},
},
],
} as any);
return {
claimRequestId: String(newClaim._id),
publicId: blameRequest.publicId,
status: ClaimCaseStatus.SELECTING_OUTER_PARTS,
message: "Claim created successfully. Registrar can now fill claim data.",
};
}
/**
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
*
@@ -2893,6 +3139,7 @@ export class ClaimRequestManagementService {
claimRequestId: string,
body: SelectOuterPartsV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOuterPartsV2ResponseDto> {
try {
// 1. Validate claim exists
@@ -2904,8 +3151,13 @@ export class ClaimRequestManagementService {
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
// 2. Validate user is the claim owner (or field expert acting for IN_PERSON claim)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can select damaged parts'
);
@@ -3007,6 +3259,7 @@ export class ClaimRequestManagementService {
claimRequestId: string,
body: SelectOtherPartsV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOtherPartsV2ResponseDto> {
try {
// 1. Validate claim exists
@@ -3018,8 +3271,13 @@ export class ClaimRequestManagementService {
);
}
// 2. Validate user is the claim owner (damaged party)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
// 2. Validate user is the claim owner (or field expert for IN_PERSON claim)
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException(
'Only the claim owner (damaged party) can submit other parts and bank information'
);
@@ -3120,6 +3378,7 @@ export class ClaimRequestManagementService {
async getCaptureRequirementsV2(
claimRequestId: string,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<GetCaptureRequirementsV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3128,7 +3387,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('Only the claim owner can view capture requirements');
}
@@ -3152,7 +3416,11 @@ export class ClaimRequestManagementService {
return labels[key] || { fa: key, en: key };
};
// Required documents
// Required documents: CAR_BODY needs 7 (damaged + green card); THIRD_PARTY needs 13 (add guilty_*)
const blameRequest = claimCase.blameRequestId
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
: null;
const isCarBody = blameRequest?.type === BlameRequestType.CAR_BODY;
const requiredDocsDefinition = [
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' },
{ key: 'damaged_driving_license_front', label_fa: 'گواهینامه طرف آسیب‌دیده - جلو', label_en: 'Damaged Party License - Front', category: 'damaged_party' },
@@ -3162,11 +3430,13 @@ export class ClaimRequestManagementService {
{ key: 'damaged_car_card_front', label_fa: 'کارت خودرو آسیب‌دیده - جلو', label_en: 'Damaged Car Card - Front', category: 'damaged_party' },
{ key: 'damaged_car_card_back', label_fa: 'کارت خودرو آسیب‌دیده - پشت', label_en: 'Damaged Car Card - Back', category: 'damaged_party' },
{ key: 'damaged_metal_plate', label_fa: 'پلاک فلزی آسیب‌دیده', label_en: 'Damaged Metal Plate', category: 'damaged_party' },
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
...(isCarBody ? [] : [
{ key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', category: 'guilty_party' },
]),
];
const requiredDocuments = requiredDocsDefinition.map(doc => {
@@ -3250,6 +3520,7 @@ export class ClaimRequestManagementService {
body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<UploadRequiredDocumentV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3258,7 +3529,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('Only the claim owner can upload documents');
}
@@ -3268,6 +3544,16 @@ export class ClaimRequestManagementService {
);
}
// CAR_BODY claims only allow 7 document types (no guilty_*)
const blameForUpload = claimCase.blameRequestId
? await this.blameRequestDbService.findById(claimCase.blameRequestId.toString())
: null;
const isCarBodyUpload = blameForUpload?.type === BlameRequestType.CAR_BODY;
const guiltyKeys = ['guilty_driving_license_front', 'guilty_driving_license_back', 'guilty_car_card_front', 'guilty_car_card_back', 'guilty_metal_plate'];
if (isCarBodyUpload && guiltyKeys.includes(body.documentKey)) {
throw new BadRequestException(`Document type ${body.documentKey} is not required for CAR_BODY claims`);
}
// Check if document already uploaded
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
if (existingDoc?.uploaded) {
@@ -3312,11 +3598,11 @@ export class ClaimRequestManagementService {
},
};
// Check if all documents are uploaded
const totalDocs = 13;
// Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY)
const totalDocsRequired = isCarBodyUpload ? 7 : 13;
const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1;
const allDocumentsUploaded = uploadedCount >= totalDocs;
const allDocumentsUploaded = uploadedCount >= totalDocsRequired;
// If all documents uploaded, move to next step
if (allDocumentsUploaded) {
@@ -3344,7 +3630,7 @@ export class ClaimRequestManagementService {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
const remaining = totalDocs - uploadedCount;
const remaining = totalDocsRequired - uploadedCount;
const message = allDocumentsUploaded
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
: `Document uploaded successfully. ${remaining} documents remaining.`;
@@ -3372,6 +3658,7 @@ export class ClaimRequestManagementService {
body: CapturePartV2Dto,
file: Express.Multer.File,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<CapturePartV2ResponseDto> {
try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3380,7 +3667,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`);
}
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claimCase,
currentUserId,
actor?.role,
);
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('Only the claim owner can capture parts');
}
@@ -3492,14 +3784,42 @@ export class ClaimRequestManagementService {
}
/**
* V2 API: Get list of claims for current user
* V2 API: Get list of claims for current user (or for FIELD_EXPERT: claims from their expert-initiated IN_PERSON blame files).
*/
async getMyClaimsV2(currentUserId: string): Promise<GetMyClaimsV2ResponseDto> {
async getMyClaimsV2(
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<GetMyClaimsV2ResponseDto> {
try {
const claims = await this.claimCaseDbService.find(
{ 'owner.userId': new Types.ObjectId(currentUserId) },
{ lean: true },
);
let claims: any[];
if (actor?.role === RoleEnum.FIELD_EXPERT) {
const expertBlameIds = await this.blameRequestDbService
.find(
{
expertInitiated: true,
creationMethod: 'IN_PERSON',
initiatedByFieldExpertId: new Types.ObjectId(currentUserId),
},
{ select: '_id', lean: true },
)
.then((docs) => docs.map((d) => (d as any)._id));
claims = await this.claimCaseDbService.find(
{
$or: [
{ 'owner.userId': new Types.ObjectId(currentUserId) },
...(expertBlameIds.length
? [{ blameRequestId: { $in: expertBlameIds } }]
: []),
],
},
{ lean: true },
);
} else {
claims = await this.claimCaseDbService.find(
{ 'owner.userId': new Types.ObjectId(currentUserId) },
{ lean: true },
);
}
const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(),
publicId: c.publicId,
@@ -3524,13 +3844,19 @@ export class ClaimRequestManagementService {
async getClaimDetailsV2(
claimRequestId: string,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<ClaimDetailsV2ResponseDto> {
try {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException('Claim request not found');
}
if (!claim.owner?.userId || claim.owner.userId.toString() !== currentUserId) {
const effectiveUserId = await this.resolveClaimEffectiveUserId(
claim,
currentUserId,
actor?.role,
);
if (!claim.owner?.userId || claim.owner.userId.toString() !== effectiveUserId) {
throw new ForbiddenException('You do not have access to this claim');
}

View File

@@ -33,7 +33,7 @@ import { ClaimDetailsV2ResponseDto } from "./dto/claim-details-v2.dto";
@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 +51,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 +91,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -231,6 +232,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -345,6 +347,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId,
body,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -397,6 +400,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;
@@ -434,16 +438,16 @@ Returns status of each item (uploaded/captured or not).
description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
**Upload one of the 13 required documents:**
**Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card
- 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 CAPTURE_PART_DAMAGES (Step 5).
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
`,
})
@ApiParam({
@@ -507,6 +511,7 @@ Returns status of each item (uploaded/captured or not).
body,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -547,6 +552,8 @@ Returns status of each item (uploaded/captured or not).
2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.**
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
`,
})
@ApiParam({
@@ -598,6 +605,7 @@ Returns status of each item (uploaded/captured or not).
body,
file,
user.sub,
user,
);
} catch (error) {
if (error instanceof HttpException) throw error;

View File

@@ -133,6 +133,9 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[];
@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

@@ -67,7 +67,7 @@ export class ExpertBlameService {
private readonly expertDbService: ExpertDbService,
private readonly blameDocumentDbService: BlameDocumentDbService,
private readonly userSignDbService: UserSignDbService,
) {}
) { }
async findAll(actor: any): Promise<AllRequestDtoRs> {
// 1. Fetch all potentially relevant requests from the database.
@@ -186,7 +186,7 @@ export class ExpertBlameService {
async findAllV2(actor: any): Promise<AllRequestDtoRsV2> {
try {
const expertId = actor.sub;
// Fetch all DISAGREEMENT cases
const allCases = await this.blameRequestDbService.find(
{
@@ -198,7 +198,18 @@ export class ExpertBlameService {
// Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert
// 3. Expert-initiated: only the initiating field expert sees them
const visibleCases = (allCases as Record<string, unknown>[]).filter((doc) => {
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;
@@ -394,9 +405,9 @@ export class ExpertBlameService {
// 2. Initial validation to ensure the expert has access
// Check if locked by current expert and lock is still active
const isLockedByCurrentExpert =
const isLockedByCurrentExpert =
String(request?.actorLocked?.actorId) === actorId && request.lockFile;
// Check if lock has expired
let isLockExpired = false;
if (request.unlockTime) {
@@ -404,7 +415,7 @@ export class ExpertBlameService {
const now = Date.now();
isLockExpired = now >= unlockTime;
}
if (isLockedByCurrentExpert && !isLockExpired) {
// This is the correct expert, and the file is locked to them, which is fine.
// They can access it even if they closed the browser and came back.
@@ -415,7 +426,7 @@ export class ExpertBlameService {
// The file is locked by someone else, or lock expired but status hasn't updated yet
// Only block if lock is still active and not by current expert
if (request.lockFile && !isLockExpired && !isLockedByCurrentExpert) {
throw new BadRequestException("Request is locked by another expert");
throw new BadRequestException("Request is locked by another expert");
}
}
@@ -509,12 +520,99 @@ 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>> {
// try {
// const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
// if (!doc) {
// throw new NotFoundException("Request not found");
// }
// const type = doc.type as string;
// if (type === BlameRequestType.CAR_BODY) {
// throw new ForbiddenException(
// "CAR_BODY type requests are automatically handled and do not require expert review.",
// );
// }
// // Access control: Expert-initiated files only visible to the initiating field expert
// 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.",
// );
// }
// // Allow if:
// // 1. No decision yet (fresh request)
// // 2. Decision made by current expert
// const decision = (doc.expert as any)?.decision;
// const decidedByExpertId = decision?.decidedByExpertId;
// 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;
// }>;
// for (const party of parties) {
// 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);
// }
// }
// 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));
// }
// }
// 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();
// 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;
// }
// 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 findOneV2(requestId: string, actorId: string): Promise<Record<string, unknown>> {
try {
const doc = await this.blameRequestDbService.findByIdWithoutHistory(requestId);
if (!doc) {
throw new NotFoundException("Request not found");
}
if (!doc) throw new NotFoundException("Request not found");
const type = doc.type as string;
if (type === BlameRequestType.CAR_BODY) {
throw new ForbiddenException(
@@ -522,74 +620,100 @@ 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 = (doc.parties ?? []);
const typedParties = parties as Array<{
vehicle?: {
inquiry?: {
mapped?: any; // Use 'any' or a more specific type if known
// other inquiry properties
};
// other vehicle properties
};
evidence?: { // For the second part of your original error description
videoId?: string | number;
voices?: (string | number)[];
videoUrl?: string;
voiceUrls?: string[];
};
// other party properties
}>;
for (const party of parties) {
// Extract inquiry.mapped for each party if needed, but we will keep the full vehicle object.
for (const party of typedParties) {
const mapped = party?.vehicle?.inquiry?.mapped;
// If you still want this field, you can add it like:
// party.inquiryMapped = mapped ?? null;
// But based on your last request, we are keeping the full vehicle object.
}
// Evidence (videos + voices)
for (const party of typedParties) {
if (!party.evidence) continue;
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;
}
// The line `delete party.vehicle;` has been removed, so vehicle will be included.
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 fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
@@ -660,6 +784,17 @@ 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;
@@ -968,6 +1103,124 @@ export class ExpertBlameService {
}
}
async submitInPerson(
requestId: string,
actorId: string,
) {
try {
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() + 15 * 60 * 1000;
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,
@@ -1040,7 +1293,7 @@ export class ExpertBlameService {
"Access denied to this request. You are not the locked expert.",
);
}
// Check if lock has expired (unlockTime has passed)
if (request.unlockTime) {
const unlockTime = new Date(request.unlockTime).getTime();
@@ -1051,7 +1304,7 @@ export class ExpertBlameService {
} else if (request.unlockTime == null) {
throw new ForbiddenException("Your lock time has expired.");
}
if (!request.lockFile) {
throw new ForbiddenException(
"You must lock the request before submitting a reply.",

View File

@@ -22,9 +22,9 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT)
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) {}
constructor(private readonly expertBlameService: ExpertBlameService) { }
@Get()
async findAll(@CurrentUser() actor: any) {
@@ -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

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

View File

@@ -36,6 +36,7 @@ import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/clai
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
import { ClaimDetailV2ResponseDto } from "./dto/claim-detail-v2.dto";
import { ClaimSubmitReplyDto, ClaimSubmitResendDto } from "./dto/reply.dto";
import { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
import { ClaimFactorsImageDbService } from "src/claim-request-management/entites/db-service/factor-image.db.service";
import { ClaimRequiredDocumentDbService } from "src/claim-request-management/entites/db-service/claim-required-document.db.service";
import { FactorStatus } from "src/Types&Enums/claim-request-management/factor-status.enum";
@@ -1969,4 +1970,77 @@ export class ExpertClaimService {
updatedAt: (claim as any).updatedAt,
};
}
/**
* V2: Damage expert can modify selected damaged parts before final submit.
* Allowed only when claim is EXPERT_REVIEWING and locked by this expert.
*/
async updateClaimDamagedPartsV2(
claimRequestId: string,
body: UpdateClaimDamagedPartsV2Dto,
actor: any,
) {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) {
throw new NotFoundException("Claim request not found");
}
if (claim.status !== ClaimCaseStatus.EXPERT_REVIEWING) {
throw new BadRequestException(
`Claim is not in EXPERT_REVIEWING state. Current status: ${claim.status}`,
);
}
if (!claim.workflow?.locked) {
throw new ForbiddenException(
"You must lock the claim before modifying damaged parts",
);
}
if (claim.workflow.lockedBy?.actorId?.toString() !== actor.sub) {
throw new ForbiddenException("This claim is locked by another expert");
}
const previous = Array.isArray((claim as any).damage?.selectedParts)
? [...(claim as any).damage.selectedParts]
: [];
const selectedParts = body.selectedParts as string[];
if (!(claim as any).damage) (claim as any).damage = {};
(claim as any).damage.selectedParts = selectedParts;
// Keep damaged-parts captures consistent with updated selection.
const damagedPartsMedia = (claim as any).media?.damagedParts;
if (damagedPartsMedia) {
const selectedSet = new Set(selectedParts);
if (damagedPartsMedia instanceof Map) {
for (const key of Array.from(damagedPartsMedia.keys())) {
if (!selectedSet.has(key)) damagedPartsMedia.delete(key);
}
} else {
for (const key of Object.keys(damagedPartsMedia)) {
if (!selectedSet.has(key)) delete damagedPartsMedia[key];
}
}
}
if (!Array.isArray((claim as any).history)) (claim as any).history = [];
(claim as any).history.push({
event: "EXPERT_DAMAGED_PARTS_UPDATED",
performedBy: actor.sub,
performedByName: actor.fullName,
performedAt: new Date(),
note: "Damage expert edited selected damaged parts",
metadata: {
previousSelectedParts: previous,
selectedParts,
},
});
await (claim as any).save();
return {
claimRequestId: claim._id.toString(),
selectedParts,
previousSelectedParts: previous,
message: "Damaged parts updated successfully.",
};
}
}

View File

@@ -15,6 +15,7 @@ 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 { UpdateClaimDamagedPartsV2Dto } from "./dto/update-claim-damaged-parts-v2.dto";
class InPersonVisitV2Dto {
@ApiPropertyOptional({ example: 'Paint damage requires physical inspection' })
@@ -107,4 +108,24 @@ export class ExpertClaimV2Controller {
body?.note,
);
}
@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,
);
}
}

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"
@ApiPropertyOptional({
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"
@ApiPropertyOptional({
description: "CAR_BODY only. Ignored for THIRD_PARTY.",
example: "14:30",
})
accidentTime?: string;
@ApiPropertyOptional({
@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({
@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({
@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,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,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

@@ -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[];
}
@@ -44,9 +52,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

@@ -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,553 @@
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 { ExpertCompleteThirdPartyFormDto } from "./dto/expert-complete-third-party-form.dto";
import { ExpertCompleteCarBodyFormDto } from "./dto/expert-complete-car-body-form.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 { ExpertCompleteLocationsDto } from "./dto/expert-complete-locations.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 locations. After this, call add-locations to submit 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. All nested fields are listed so you can fill or test without guessing property names.",
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",
},
firstPartyDescription: { 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",
},
description: { desc: "توضیح حادثه طرف دوم" },
},
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",
},
firstPartyDescription: {
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: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(
expert,
requestId,
formData,
);
}
@Post("add-locations/:requestId")
@ApiOperation({
summary: "[V2] Submit location(s) for expert-initiated IN_PERSON blame",
description:
"Submit first party (and second party for THIRD_PARTY) location after complete-blame-data. This transitions workflow to WAITING_FOR_SIGNATURES.",
})
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
type: ExpertCompleteLocationsDto,
examples: {
THIRD_PARTY: {
summary: "THIRD_PARTY locations",
value: {
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
secondPartyLocation: { lat: 35.7001, lon: 51.4102 },
},
},
CAR_BODY: {
summary: "CAR_BODY location",
value: {
firstPartyLocation: { 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: ExpertCompleteLocationsDto,
) {
return this.requestManagementService.expertAddLocationsForBlameV2(
expert,
requestId,
dto,
);
}
@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

@@ -33,6 +33,7 @@ import {
CreateBlameRequestDtoV2,
DescriptionDto,
LocationDto,
CarBodyFormDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
@@ -50,7 +51,7 @@ import { RequestManagementService } from "./request-management.service";
export class RequestManagementV2Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
) {}
) { }
@Post()
@UseGuards(GlobalGuard)
@@ -64,6 +65,27 @@ export class RequestManagementV2Controller {
);
}
@Get()
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getAllBlameRequestsV2(
@CurrentUser() user: any,
) {
return this.requestManagementService.getAllBlameRequestsV2(user);
}
/**
* Get one blame request by id. Allowed for the request owner (party) or the initiating field expert.
*/
@Get(":requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getBlameRequestV2(
@Param("requestId") requestId: string,
@CurrentUser() user: any,
) {
return this.requestManagementService.getBlameRequestV2(requestId, user);
}
@Post("/blame-confession/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: BlameConfessionDtoV2 })
@@ -76,6 +98,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 +220,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,
@@ -276,10 +332,10 @@ export class RequestManagementV2Controller {
@UploadedFile() sign: Express.Multer.File,
) {
// Convert string "true"/"false" to boolean
const acceptDecision = typeof isAccept === "string"
? isAccept === "true"
const acceptDecision = typeof isAccept === "string"
? isAccept === "true"
: Boolean(isAccept);
return this.requestManagementService.userSignDecisionV2(
requestId,
user.sub,

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);
// Map the new API response format to the old format
let result = this.mapNewApiResponseToOldFormat(response);
const base = process.env.SANDHUB_BASE_URL ?? "";
const requestUrl = `${base}/block-inquiry-tejarat`;
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({
sender: "10008663",
...data,
});
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

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

@@ -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...
];