Compare commits

...

13 Commits

Author SHA1 Message Date
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
36 changed files with 4731 additions and 1662 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", "class-validator": "^0.14.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.22.1",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"form-data": "^4.0.2", "form-data": "^4.0.2",

View File

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

View File

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

View File

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

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

View File

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

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.DAMAGE_EXPERT,
RoleEnum.COMPANY, RoleEnum.COMPANY,
RoleEnum.FIELD_EXPERT, RoleEnum.FIELD_EXPERT,
RoleEnum.REGISTRAR,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");

View File

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

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 { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum"; import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { CaseStatus as BlameCaseStatus } from "src/Types&Enums/blame-request-management/caseStatus.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 { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service"; import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-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"; const isCarBody = claimRequest.blameFile?.type === "CAR_BODY";
if (isCarBody) { if (isCarBody) {
// Check if accident is with another object (not a car) // Accident with object (not another car): v2 uses parties[0].carBodyFirstForm.object, v1 uses carBodyForm.carBodyForm.car === false
// This can be determined by: const firstPartyCarBody = claimRequest.blameFile?.parties?.[0]?.carBodyFirstForm;
// 1. carBodyForm.carBodyForm.car === false (explicitly set to false) const isAccidentWithOtherObject =
// 2. OR no secondPartyCarDetail exists (no second party car) (firstPartyCarBody && firstPartyCarBody.object === true) ||
const isAccidentWithOtherObject =
claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false || claimRequest.blameFile?.carBodyForm?.carBodyForm?.car === false ||
!claimRequest.blameFile?.secondPartyDetails?.secondPartyCarDetail; !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 || []; const parties = blameRequest.parties || [];
if (parties.length < 2) { const isCarBody = blameRequest.type === BlameRequestType.CAR_BODY;
throw new BadRequestException(
"Both parties must be present in the blame request", 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( // 5. Validate current user is a party (and for THIRD_PARTY not the guilty one)
(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)
const currentUserParty = parties.find( const currentUserParty = parties.find(
(p) => String(p.person?.userId) === currentUserId, (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 // 6. Validate no existing claim for this blame
const existingClaim = await this.claimCaseDbService.findOne({ const existingClaim = await this.claimCaseDbService.findOne({
blameRequestId: new Types.ObjectId(blameRequestId), 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) * V2 API: Select damaged outer car parts for claim (Step 2 of claim workflow)
* *
@@ -2893,6 +3139,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
body: SelectOuterPartsV2Dto, body: SelectOuterPartsV2Dto,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOuterPartsV2ResponseDto> { ): Promise<SelectOuterPartsV2ResponseDto> {
try { try {
// 1. Validate claim exists // 1. Validate claim exists
@@ -2904,8 +3151,13 @@ export class ClaimRequestManagementService {
); );
} }
// 2. Validate user is the claim owner (damaged party) const effectiveUserId = await this.resolveClaimEffectiveUserId(
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) { 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( throw new ForbiddenException(
'Only the claim owner (damaged party) can select damaged parts' 'Only the claim owner (damaged party) can select damaged parts'
); );
@@ -3007,6 +3259,7 @@ export class ClaimRequestManagementService {
claimRequestId: string, claimRequestId: string,
body: SelectOtherPartsV2Dto, body: SelectOtherPartsV2Dto,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<SelectOtherPartsV2ResponseDto> { ): Promise<SelectOtherPartsV2ResponseDto> {
try { try {
// 1. Validate claim exists // 1. Validate claim exists
@@ -3018,8 +3271,13 @@ export class ClaimRequestManagementService {
); );
} }
// 2. Validate user is the claim owner (damaged party) const effectiveUserId = await this.resolveClaimEffectiveUserId(
if (!claimCase.owner?.userId || claimCase.owner.userId.toString() !== currentUserId) { 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( throw new ForbiddenException(
'Only the claim owner (damaged party) can submit other parts and bank information' 'Only the claim owner (damaged party) can submit other parts and bank information'
); );
@@ -3120,6 +3378,7 @@ export class ClaimRequestManagementService {
async getCaptureRequirementsV2( async getCaptureRequirementsV2(
claimRequestId: string, claimRequestId: string,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<GetCaptureRequirementsV2ResponseDto> { ): Promise<GetCaptureRequirementsV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3128,7 +3387,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 }; 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 = [ const requiredDocsDefinition = [
{ key: 'car_green_card', label_fa: 'کارت سبز خودرو', label_en: 'Car Green Card', category: 'general' }, { 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' }, { 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_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_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: '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' }, ...(isCarBody ? [] : [
{ key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' }, { key: 'guilty_driving_license_front', label_fa: 'گواهینامه طرف مقصر - جلو', label_en: 'Guilty Party License - Front', category: 'guilty_party' },
{ key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' }, { key: 'guilty_driving_license_back', label_fa: 'گواهینامه طرف مقصر - پشت', label_en: 'Guilty Party License - Back', category: 'guilty_party' },
{ key: 'guilty_car_card_back', label_fa: 'کارت خودرو مقصر - پشت', label_en: 'Guilty Car Card - Back', category: 'guilty_party' }, { key: 'guilty_car_card_front', label_fa: 'کارت خودرو مقصر - جلو', label_en: 'Guilty Car Card - Front', category: 'guilty_party' },
{ key: 'guilty_metal_plate', label_fa: 'پلاک فلزی مقصر', label_en: 'Guilty Metal Plate', 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 => { const requiredDocuments = requiredDocsDefinition.map(doc => {
@@ -3250,6 +3520,7 @@ export class ClaimRequestManagementService {
body: UploadRequiredDocumentV2Dto, body: UploadRequiredDocumentV2Dto,
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3258,7 +3529,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 // Check if document already uploaded
const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey); const existingDoc = (claimCase.requiredDocuments as any)?.get?.(body.documentKey);
if (existingDoc?.uploaded) { if (existingDoc?.uploaded) {
@@ -3312,11 +3598,11 @@ export class ClaimRequestManagementService {
}, },
}; };
// Check if all documents are uploaded // Check if all documents are uploaded (7 for CAR_BODY, 13 for THIRD_PARTY)
const totalDocs = 13; const totalDocsRequired = isCarBodyUpload ? 7 : 13;
const currentDocs = claimCase.requiredDocuments || new Map(); const currentDocs = claimCase.requiredDocuments || new Map();
const uploadedCount = (currentDocs instanceof Map ? currentDocs.size : Object.keys(currentDocs).length) + 1; 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 all documents uploaded, move to next step
if (allDocumentsUploaded) { if (allDocumentsUploaded) {
@@ -3344,7 +3630,7 @@ export class ClaimRequestManagementService {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData); await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, updateData);
const remaining = totalDocs - uploadedCount; const remaining = totalDocsRequired - uploadedCount;
const message = allDocumentsUploaded const message = allDocumentsUploaded
? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.' ? 'All documents uploaded successfully. Please proceed to capture car angles and damaged parts.'
: `Document uploaded successfully. ${remaining} documents remaining.`; : `Document uploaded successfully. ${remaining} documents remaining.`;
@@ -3372,6 +3658,7 @@ export class ClaimRequestManagementService {
body: CapturePartV2Dto, body: CapturePartV2Dto,
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<CapturePartV2ResponseDto> { ): Promise<CapturePartV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -3380,7 +3667,12 @@ export class ClaimRequestManagementService {
throw new NotFoundException(`Claim case with ID ${claimRequestId} not found`); 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'); 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 { try {
const claims = await this.claimCaseDbService.find( let claims: any[];
{ 'owner.userId': new Types.ObjectId(currentUserId) }, if (actor?.role === RoleEnum.FIELD_EXPERT) {
{ lean: true }, 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) => ({ const list = (claims as any[]).map((c) => ({
claimRequestId: c._id.toString(), claimRequestId: c._id.toString(),
publicId: c.publicId, publicId: c.publicId,
@@ -3524,13 +3844,19 @@ export class ClaimRequestManagementService {
async getClaimDetailsV2( async getClaimDetailsV2(
claimRequestId: string, claimRequestId: string,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string },
): Promise<ClaimDetailsV2ResponseDto> { ): Promise<ClaimDetailsV2ResponseDto> {
try { try {
const claim = await this.claimCaseDbService.findById(claimRequestId); const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) { if (!claim) {
throw new NotFoundException('Claim request not found'); throw new NotFoundException('Claim request not found');
} }
if (!claim.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'); 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") @Controller("v2/claim-request-management")
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(GlobalGuard, RolesGuard) @UseGuards(GlobalGuard, RolesGuard)
@Roles(RoleEnum.USER) @Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
export class ClaimRequestManagementV2Controller { export class ClaimRequestManagementV2Controller {
constructor( constructor(
private readonly claimRequestManagementService: ClaimRequestManagementService, private readonly claimRequestManagementService: ClaimRequestManagementService,
@@ -51,7 +51,7 @@ export class ClaimRequestManagementV2Controller {
}) })
async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> { async getMyClaims(@CurrentUser() user: any): Promise<GetMyClaimsV2ResponseDto> {
try { try {
return await this.claimRequestManagementService.getMyClaimsV2(user.sub); return await this.claimRequestManagementService.getMyClaimsV2(user.sub, user);
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
throw new InternalServerErrorException( throw new InternalServerErrorException(
@@ -91,6 +91,7 @@ export class ClaimRequestManagementV2Controller {
return await this.claimRequestManagementService.getClaimDetailsV2( return await this.claimRequestManagementService.getClaimDetailsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -231,6 +232,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -345,6 +347,7 @@ export class ClaimRequestManagementV2Controller {
claimRequestId, claimRequestId,
body, body,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -397,6 +400,7 @@ Returns status of each item (uploaded/captured or not).
return await this.claimRequestManagementService.getCaptureRequirementsV2( return await this.claimRequestManagementService.getCaptureRequirementsV2(
claimRequestId, claimRequestId,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -434,16 +438,16 @@ Returns status of each item (uploaded/captured or not).
description: ` description: `
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim) **Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 4 of Claim)
**Upload one of the 13 required documents:** **Upload one of the required documents** (13 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
- car_green_card - car_green_card
- damaged_driving_license_front/back - damaged_driving_license_front/back
- damaged_chassis_number, damaged_engine_photo - damaged_chassis_number, damaged_engine_photo
- damaged_car_card_front/back, damaged_metal_plate - damaged_car_card_front/back, damaged_metal_plate
- guilty_driving_license_front/back - guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
- guilty_car_card_front/back, guilty_metal_plate
**When all 13 documents are uploaded:** **When all required documents are uploaded:** Workflow moves to CAPTURE_PART_DAMAGES (Step 5).
- Workflow automatically moves to: CAPTURE_PART_DAMAGES (Step 5)
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to upload documents on behalf of the damaged party.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -507,6 +511,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;
@@ -547,6 +552,8 @@ Returns status of each item (uploaded/captured or not).
2. **part**: Damaged parts based on selectedParts from Step 2 2. **part**: Damaged parts based on selectedParts from Step 2
**All captures must be completed before user can submit claim.** **All captures must be completed before user can submit claim.**
**Field expert IN_PERSON:** Same endpoint; use with claim created from expert-initiated IN_PERSON blame to capture photos on behalf of the damaged party.
`, `,
}) })
@ApiParam({ @ApiParam({
@@ -598,6 +605,7 @@ Returns status of each item (uploaded/captured or not).
body, body,
file, file,
user.sub, user.sub,
user,
); );
} catch (error) { } catch (error) {
if (error instanceof HttpException) throw error; if (error instanceof HttpException) throw error;

View File

@@ -133,6 +133,9 @@ export class ClaimCase {
@Prop({ type: [HistoryEventSchema], default: [] }) @Prop({ type: [HistoryEventSchema], default: [] })
history?: HistoryEvent[]; history?: HistoryEvent[];
@Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId;
/** /**
* Legacy fields kept optional to simplify progressive migration. * Legacy fields kept optional to simplify progressive migration.
* If you choose to migrate later, we can remove these. * If you choose to migrate later, we can remove these.

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

@@ -198,7 +198,18 @@ export class ExpertBlameService {
// Filter to show only: // Filter to show only:
// 1. Fresh requests (WAITING_FOR_EXPERT and no decision) // 1. Fresh requests (WAITING_FOR_EXPERT and no decision)
// 2. Requests decided by current expert // 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 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 status = doc.status as string;
const decision = doc.expert as any; const decision = doc.expert as any;
const decidedByExpertId = decision?.decision?.decidedByExpertId; const decidedByExpertId = decision?.decision?.decidedByExpertId;
@@ -522,13 +533,20 @@ export class ExpertBlameService {
); );
} }
// Access control: Check if expert has permission to view this request // Access control: Expert-initiated files only visible to the initiating field expert
const decision = (doc.expert as any)?.decision; const expertInitiated = doc.expertInitiated === true;
const decidedByExpertId = decision?.decidedByExpertId; 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: // Allow if:
// 1. No decision yet (fresh request) // 1. No decision yet (fresh request)
// 2. Decision made by current expert // 2. Decision made by current expert
const decision = (doc.expert as any)?.decision;
const decidedByExpertId = decision?.decidedByExpertId;
if (decidedByExpertId && String(decidedByExpertId) !== actorId) { if (decidedByExpertId && String(decidedByExpertId) !== actorId) {
throw new ForbiddenException( throw new ForbiddenException(
"You do not have permission to view this request. It has been handled by another expert.", "You do not have permission to view this request. It has been handled by another expert.",
@@ -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 // Check if locked and not expired
if (request.workflow?.locked) { if (request.workflow?.locked) {
const lockedAt = request.workflow.lockedAt; const lockedAt = request.workflow.lockedAt;

View File

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

View File

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

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 }) @Prop({ type: AccidentClassificationSchema })
classification?: AccidentClassification; classification?: AccidentClassification;
/** CAR_BODY: weather / road / light at time of accident */
@Prop() weatherCondition?: string;
@Prop() roadCondition?: string;
@Prop() lightCondition?: string;
} }
export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo); export const AccidentInfoSchema = SchemaFactory.createForClass(AccidentInfo);

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import {
CreateBlameRequestDtoV2, CreateBlameRequestDtoV2,
DescriptionDto, DescriptionDto,
LocationDto, LocationDto,
CarBodyFormDto,
} from "./dto/create-request-management.dto"; } from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service"; import { RequestManagementService } from "./request-management.service";
@@ -50,7 +51,7 @@ import { RequestManagementService } from "./request-management.service";
export class RequestManagementV2Controller { export class RequestManagementV2Controller {
constructor( constructor(
private readonly requestManagementService: RequestManagementService, private readonly requestManagementService: RequestManagementService,
) {} ) { }
@Post() @Post()
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
@@ -64,6 +65,27 @@ export class RequestManagementV2Controller {
); );
} }
@Get()
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getAllBlameRequestsV2(
@CurrentUser() user: any,
) {
return this.requestManagementService.getAllBlameRequestsV2(user);
}
/**
* Get one blame request by id. Allowed for the request owner (party) or the initiating field expert.
*/
@Get(":requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT)
async getBlameRequestV2(
@Param("requestId") requestId: string,
@CurrentUser() user: any,
) {
return this.requestManagementService.getBlameRequestV2(requestId, user);
}
@Post("/blame-confession/:requestId") @Post("/blame-confession/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: BlameConfessionDtoV2 }) @ApiBody({ type: BlameConfessionDtoV2 })
@@ -76,6 +98,19 @@ export class RequestManagementV2Controller {
return this.requestManagementService.blameConfessionV2(requestId, body, user); return this.requestManagementService.blameConfessionV2(requestId, body, user);
} }
/** CAR_BODY only: submit accident type (car vs object). Call this when nextStep is CAR_BODY_ACCIDENT_TYPE. */
@Post("/car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@UseGuards(GlobalGuard)
async carBodyFormV2(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() user,
) {
return this.requestManagementService.carBodyAccidentTypeFormV2(requestId, body, user);
}
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",
@@ -185,7 +220,28 @@ export class RequestManagementV2Controller {
@Post("/add-detail-description/:requestId") @Post("/add-detail-description/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto }) @ApiBody({
description:
"THIRD_PARTY: send only desc. CAR_BODY: send desc + accidentDate, accidentTime, weatherCondition, roadCondition, lightCondition (all required).",
type: DescriptionDto,
examples: {
thirdParty: {
summary: "THIRD_PARTY (only desc)",
value: { desc: "Accident description text" },
},
carBody: {
summary: "CAR_BODY (desc + conditions)",
value: {
desc: "Accident description",
accidentDate: "2025-12-08",
accidentTime: "14:30",
weatherCondition: "صاف",
roadCondition: "خشک",
lightCondition: "روز",
},
},
},
})
@UseGuards(GlobalGuard) @UseGuards(GlobalGuard)
async addDescriptionV2( async addDescriptionV2(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@@ -276,10 +332,10 @@ export class RequestManagementV2Controller {
@UploadedFile() sign: Express.Multer.File, @UploadedFile() sign: Express.Multer.File,
) { ) {
// Convert string "true"/"false" to boolean // Convert string "true"/"false" to boolean
const acceptDecision = typeof isAccept === "string" const acceptDecision = typeof isAccept === "string"
? isAccept === "true" ? isAccept === "true"
: Boolean(isAccept); : Boolean(isAccept);
return this.requestManagementService.userSignDecisionV2( return this.requestManagementService.userSignDecisionV2(
requestId, requestId,
user.sub, user.sub,

View File

@@ -183,7 +183,79 @@ export class SandHubService {
}; };
const requestUrl = `${baseUrl}/block-inquiry-tejarat`; 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); const mapped = this.mapNewApiResponseToOldFormat(raw);
return { raw, mapped }; return { raw, mapped };
} }
@@ -293,15 +365,88 @@ export class SandHubService {
rightTwoDigits: String(userDetail.plate.ir), rightTwoDigits: String(userDetail.plate.ir),
nationalCode: userDetail.nationalCodeOfInsurer, nationalCode: userDetail.nationalCodeOfInsurer,
}; };
const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`; // const requestUrl = `${process.env.SANDHUB_BASE_URL}/block-inquiry-tejarat`;
const response = await this.makeSandHubRequest(requestUrl, requestPayload); // const response = await this.makeSandHubRequest(requestUrl, requestPayload);
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 // // Map the new API response format to the old format
let result = this.mapNewApiResponseToOldFormat(response); let result = this.mapNewApiResponseToOldFormat(response);
if (result.usgCod !== "8") { // if (result.usgCod !== "8") {
throw new Error("خودرو شما شخصی / سواری نمی باشد") // throw new Error("خودرو شما شخصی / سواری نمی باشد")
} // }
return result; return result;
} catch (err) { } catch (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 { ExpertDbService } from "./entities/db-service/expert.db.service";
import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service"; import { FieldExpertDbService } from "./entities/db-service/field-expert.db.service";
import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service"; import { InsurerExpertDbService } from "./entities/db-service/insurer-expert.db.service";
import { RegistrarDbService } from "./entities/db-service/registrar.db.service";
import { UserDbService } from "./entities/db-service/user.db.service"; import { UserDbService } from "./entities/db-service/user.db.service";
import { import {
DamageExpertDbSchema, DamageExpertDbSchema,
@@ -21,6 +22,10 @@ import {
InsurerExpertDbSchema, InsurerExpertDbSchema,
InsurerExpertModel, InsurerExpertModel,
} from "./entities/schema/insurer-expert.schema"; } from "./entities/schema/insurer-expert.schema";
import {
RegistrarDbSchema,
RegistrarModel,
} from "./entities/schema/registrar.schema";
import { UserModel, UserDbSchema } from "./entities/schema/user.schema"; import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
@Module({ @Module({
@@ -31,6 +36,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
{ name: ExpertModel.name, schema: ExpertDbSchema }, { name: ExpertModel.name, schema: ExpertDbSchema },
{ name: FieldExpertModel.name, schema: FieldExpertDbSchema }, { name: FieldExpertModel.name, schema: FieldExpertDbSchema },
{ name: InsurerExpertModel.name, schema: InsurerExpertDbSchema }, { name: InsurerExpertModel.name, schema: InsurerExpertDbSchema },
{ name: RegistrarModel.name, schema: RegistrarDbSchema },
]), ]),
OtpModule, OtpModule,
HashModule, HashModule,
@@ -42,6 +48,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService, DamageExpertDbService,
FieldExpertDbService, FieldExpertDbService,
InsurerExpertDbService, InsurerExpertDbService,
RegistrarDbService,
], ],
exports: [ exports: [
UserDbService, UserDbService,
@@ -49,6 +56,7 @@ import { UserModel, UserDbSchema } from "./entities/schema/user.schema";
DamageExpertDbService, DamageExpertDbService,
FieldExpertDbService, FieldExpertDbService,
InsurerExpertDbService, InsurerExpertDbService,
RegistrarDbService,
], ],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -128,9 +128,9 @@ export class CreateWorkflowStepDto {
stepKey: WorkflowStep | ClaimWorkflowStep; stepKey: WorkflowStep | ClaimWorkflowStep;
@ApiProperty({ @ApiProperty({
description: 'Workflow type: THIRD_PARTY (blame) or CLAIM (claim workflow)', description: 'Workflow type: THIRD_PARTY (blame), CAR_BODY (blame), or CLAIM (claim workflow)',
example: 'THIRD_PARTY', example: 'THIRD_PARTY',
enum: ['THIRD_PARTY', 'CLAIM'] enum: ['THIRD_PARTY', 'CAR_BODY', 'CLAIM']
}) })
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()

View File

@@ -1123,6 +1123,43 @@ export class WorkflowStepManagementService {
party: 'second' party: 'second'
} }
}, },
// CAR_BODY workflow: accident type form (replaces confession for CAR_BODY)
{
stepKey: WorkflowStep.CAR_BODY_ACCIDENT_TYPE,
type: 'CAR_BODY',
stepNumber: 2,
isActive: true,
stepName_fa: 'نوع حادثه بدنه',
stepName_en: 'Car Body Accident Type',
description_fa: 'انتخاب اینکه حادثه با خودرو بوده یا با شیء',
description_en: 'Select whether accident was with another car or with an object',
category: 'FIRST_PARTY',
requiredPreviousSteps: [WorkflowStep.CREATED],
nextPossibleSteps: [WorkflowStep.FIRST_VIDEO],
allowedRoles: ['user'],
estimatedDuration: 1,
fields: [
{
id: '507f1f77bcf86cd7994390cb' as any,
name_fa: 'نوع حادثه',
name_en: 'accidentType',
label_fa: 'نوع حادثه',
label_en: 'Accident Type',
placeholder_fa: 'حادثه با خودرو یا شیء؟',
placeholder_en: 'Accident with car or object?',
value: [
{ label_fa: 'با خودرو', label_en: 'With Car', value: 'car' },
{ label_fa: 'با شیء', label_en: 'With Object', value: 'object' }
],
dataType: 'select',
required: true,
order: 1,
isActive: true,
validation: { enum: ['car', 'object'] }
}
],
metadata: { icon: 'car', color: '#10B981' }
},
// Add more default steps as needed... // Add more default steps as needed...
]; ];