Compare commits

...

8 Commits

22 changed files with 4044 additions and 1656 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

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

View File

@@ -71,6 +71,12 @@ export class UserAuthService {
});
const otp = this.otpCreator.create();
const hashOtp = await this.hashService.hash(otp);
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
const expireMinutes =
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
? rawExpireMinutes
: 2;
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
if (!userExist) {
await this.smsSender(otp, mobile);
/// create otp request
@@ -90,9 +96,7 @@ export class UserAuthService {
city: "",
address: "",
state: "",
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
});
return new LoginDtoRs(newUser);
}
@@ -105,9 +109,7 @@ export class UserAuthService {
},
{
otp: hashOtp,
otpExpire: new Date(
new Date().getTime() + +process.env.EXP_OTP_TIME * 60 * 1000,
).getTime(),
otpExpire,
},
);
if (updateTokens) return new LoginDtoRs(userExist);

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)
// 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,141 @@ 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 (actorRole !== RoleEnum.FIELD_EXPERT || !claimCase?.blameRequestId) {
return currentUserId;
}
const blameRequest = await this.blameRequestDbService.findById(
claimCase.blameRequestId.toString(),
);
if (
!blameRequest?.expertInitiated ||
blameRequest.creationMethod !== "IN_PERSON" ||
!blameRequest.initiatedByFieldExpertId
) {
return currentUserId;
}
if (String(blameRequest.initiatedByFieldExpertId) !== currentUserId) {
return currentUserId;
}
return claimCase.owner?.userId ? String(claimCase.owner.userId) : currentUserId;
}
/**
* V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
*
@@ -2893,6 +3034,7 @@ export class ClaimRequestManagementService {
claimRequestId: string,
body: SelectOuterPartsV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOuterPartsV2ResponseDto> {
try {
// 1. Validate claim exists
@@ -2904,8 +3046,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 +3154,7 @@ export class ClaimRequestManagementService {
claimRequestId: string,
body: SelectOtherPartsV2Dto,
currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOtherPartsV2ResponseDto> {
try {
// 1. Validate claim exists
@@ -3018,8 +3166,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 +3273,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 +3282,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 +3311,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 +3325,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 +3415,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 +3424,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 +3439,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 +3493,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 +3525,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 +3553,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 +3562,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 +3679,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 +3739,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

@@ -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;
@@ -522,13 +533,20 @@ export class ExpertBlameService {
);
}
// Access control: Check if expert has permission to view this request
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
// 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.",
@@ -660,6 +678,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;

View File

@@ -22,7 +22,7 @@ import { ResendRequestDto } from "./dto/resend.dto";
@Controller("v2/expert-blame")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.EXPERT)
@Roles(RoleEnum.EXPERT, RoleEnum.FIELD_EXPERT)
export class ExpertBlameV2Controller {
constructor(private readonly expertBlameService: ExpertBlameService) {}

View File

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

View File

@@ -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,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[];
}
@@ -45,9 +53,6 @@ export class BlameRequest {
@Prop({ type: WorkflowSchema })
workflow?: Workflow;
@Prop({ type: AccidentInfoSchema })
accident?: AccidentInfo;
@Prop({ type: [PartySchema], default: [] })
parties: Party[];
@@ -57,12 +62,32 @@ 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;
/** 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

@@ -0,0 +1,517 @@
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 { 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. After this, status becomes WAITING_FOR_SIGNATURES; use upload-party-signature to collect party 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",
},
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
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",
},
location: { lat: 35.6892, lon: 51.389 },
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",
},
firstPartyLocation: { lat: 35.6892, lon: 51.389 },
firstPartyDescription: {
desc: "توضیح حادثه",
accidentDate: "2025-01-15",
accidentTime: "14:30",
weatherCondition: "صاف",
roadCondition: "خشک",
lightCondition: "روز",
},
},
},
},
schema: { type: "object" },
})
@ApiResponse({ status: 200, description: "Blame form completed; next: upload party signature(s)" })
async completeBlameDataV2(
@CurrentUser() expert: any,
@Param("requestId") requestId: string,
@Body() formData: any,
) {
return this.requestManagementService.expertCompleteBlameDataV2(
expert,
requestId,
formData,
);
}
@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

@@ -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,7 @@ 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";
@Module({
imports: [
@@ -43,6 +46,7 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
SandHubModule,
SmsManagerModule,
PublicIdModule,
HashModule,
WorkflowStepManagementModule,
PlatesModule,
MulterModule.register({
@@ -58,11 +62,13 @@ import { ExpertInitiatedController } from "./expert-initiated.controller";
]),
forwardRef(() => ClaimRequestManagementModule),
CronModule,
AuthModule,
],
controllers: [
RequestManagementController,
RequestManagementV2Controller,
ExpertInitiatedController,
ExpertInitiatedV2Controller,
],
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,

View File

@@ -183,7 +183,79 @@ export class SandHubService {
};
const requestUrl = `${baseUrl}/block-inquiry-tejarat`;
const raw = await this.makeTejaratRequest(requestUrl, requestPayload);
// const raw = await this.makeTejaratRequest(requestUrl, requestPayload);
const raw = {
"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
}
const mapped = this.mapNewApiResponseToOldFormat(raw);
return { raw, mapped };
}
@@ -293,15 +365,88 @@ export class SandHubService {
rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer,
};
const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`;
const response = await this.makeSandHubRequest(requestUrl, requestPayload);
// const 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 response = {
"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
}
// // Map the new API response format to the old format
let result = this.mapNewApiResponseToOldFormat(response);
if (result.usgCod !== "8") {
throw new Error("خودرو شما شخصی / سواری نمی باشد")
}
// if (result.usgCod !== "8") {
// throw new Error("خودرو شما شخصی / سواری نمی باشد")
// }
return result;
} catch (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...
];