Compare commits

...

27 Commits

Author SHA1 Message Date
7c59c2407e Merge pull request 'YARA-1115, YARA-1117, YARA-1119' (#188) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#188
2026-07-14 12:07:20 +03:30
SepehrYahyaee
168e52a475 YARA-1117 and fixed completed status after in person visit has been called 2026-07-14 11:51:52 +03:30
SepehrYahyaee
4aa6e03afb YARA-1119 2026-07-14 11:32:03 +03:30
SepehrYahyaee
36a34e27b3 YARA-1115 2026-07-14 11:23:39 +03:30
SepehrYahyaee
6387ebaed0 Fixed a bug where file makers would be able to view v4 files as well as v5 ones 2026-07-14 10:19:30 +03:30
22a5990934 Merge pull request 'Fixed blame status after v4/v5 flows gets completed' (#187) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#187
2026-07-14 10:08:32 +03:30
SepehrYahyaee
e5de99adde Fixed blame status after v4/v5 flows gets completed 2026-07-14 10:07:00 +03:30
c7fd2a6b33 Merge pull request 'YARA-1110' (#186) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#186
2026-07-13 11:54:28 +03:30
SepehrYahyaee
5595083e86 YARA-1110 2026-07-13 11:53:47 +03:30
2296fa5d86 Merge pull request 'YARA-1094, YARA-1095, YARA-1096' (#185) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#185
2026-07-12 14:08:38 +03:30
SepehrYahyaee
72dec7a917 YARA-1094, YARA-1095, YARA-1096 2026-07-12 14:07:27 +03:30
67019851de Merge pull request 'YARA-982, YARA-1062, YARA-1069' (#184) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#184
2026-07-12 11:45:49 +03:30
SepehrYahyaee
c955deda5c YARA-1069 2026-07-12 11:44:32 +03:30
SepehrYahyaee
9b83db882b YARA-982 2026-07-12 11:27:58 +03:30
SepehrYahyaee
bced6a0ec7 YARA-1062 2026-07-12 11:11:50 +03:30
5d1110b6e9 Merge pull request 'YARA-947, YARA-986, YARA-1038' (#183) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#183
2026-07-11 17:52:42 +03:30
SepehrYahyaee
a7fe04c032 YARA-986 2026-07-11 17:51:14 +03:30
SepehrYahyaee
0dcb2cf2ca YARA-947, YARA-1038 2026-07-11 15:34:01 +03:30
8b125af4e7 Merge pull request 'YARA-914, YARA-917, YARA-923, YARA-937, YARA-957, YARA-1056, YARA-1061' (#182) from s.yahyaee/yara724-api:main into main
Reviewed-on: Yara724/api#182
2026-07-11 13:20:00 +03:30
1559a40213 merge upstream 2026-07-11 13:17:55 +03:30
SepehrYahyaee
54ae82aa38 YARA-1056 2026-07-11 13:16:14 +03:30
SepehrYahyaee
7a3ddcc7be YARA-937 2026-07-11 12:23:00 +03:30
SepehrYahyaee
da3f57870e YARA-917 2026-07-11 12:00:11 +03:30
ac7ee941b8 Merge pull request 'main' (#181) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#181
2026-07-11 11:40:45 +03:30
SepehrYahyaee
04f51167c2 YARA-1061 2026-07-11 11:38:49 +03:30
SepehrYahyaee
2c8fd3960f YARA-957 2026-07-11 11:25:42 +03:30
SepehrYahyaee
e59058520c YARA-914, YARA-923 2026-07-11 11:16:16 +03:30
44 changed files with 2722 additions and 142 deletions

View File

@@ -17,6 +17,18 @@ export enum CaseStatus {
*/ */
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER", WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
/**
* V5 flow only. FileReviewer has completed the claim and the owner has signed;
* the FileMaker who created the file must now approve before fanavaran submission.
*/
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
/**
* V5 flow only. FileMaker rejected the file back to FileReviewer
* for correction (adjust pricing / back-and-forth with user and re-submit).
*/
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -43,6 +43,18 @@ export enum ClaimCaseStatus {
*/ */
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER", WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
/**
* V5 split flow only. The claim is fully evaluated and owner has signed;
* the FileMaker who created the file must approve before fanavaran submission.
*/
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
/**
* V5 split flow only. FileMaker rejected the completed claim back to FileReviewer
* for correction (adjust pricing, re-do expert review, back-and-forth with user).
*/
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
// Final states // Final states
COMPLETED = "COMPLETED", COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED", CANCELLED = "CANCELLED",

View File

@@ -8,4 +8,5 @@ export enum RoleEnum {
USER = "user", USER = "user",
FILE_MAKER = "file_maker", FILE_MAKER = "file_maker",
FILE_REVIEWER = "file_reviewer", FILE_REVIEWER = "file_reviewer",
SUPER_ADMIN = "super_admin",
} }

View File

@@ -27,6 +27,7 @@ import { CronModule } from "./utils/cron/cron.module";
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
import { DatabaseModule } from "./core/database/database.module"; import { DatabaseModule } from "./core/database/database.module";
import { AppConfigModule } from "./core/config/config.module"; import { AppConfigModule } from "./core/config/config.module";
import { SuperAdminModule } from "./super-admin/super-admin.module";
@Module({ @Module({
imports: [ imports: [
@@ -59,6 +60,7 @@ import { AppConfigModule } from "./core/config/config.module";
ExpertInsurerModule, ExpertInsurerModule,
LookupsModule, LookupsModule,
WorkflowStepManagementModule, WorkflowStepManagementModule,
SuperAdminModule,
], ],
controllers: [], controllers: [],
providers: [ providers: [

View File

@@ -37,6 +37,7 @@ import {
LegalRegisterDto, LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto"; } from "src/auth/dto/actor/register.actor.dto";
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
import { Roles } from "src/decorators/roles.decorator"; import { Roles } from "src/decorators/roles.decorator";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
@@ -95,6 +96,7 @@ export class ActorAuthController {
* will be removed in a future release. * will be removed in a future release.
*/ */
@Post("register/genuine") @Post("register/genuine")
@UseGuards(SuperAdminGuard)
@ApiOperation({ @ApiOperation({
deprecated: true, deprecated: true,
summary: "[DEPRECATED] Genuine actor registration", summary: "[DEPRECATED] Genuine actor registration",
@@ -111,6 +113,7 @@ export class ActorAuthController {
* will be removed in a future release. * will be removed in a future release.
*/ */
@Post("register/legal") @Post("register/legal")
@UseGuards(SuperAdminGuard)
@ApiOperation({ @ApiOperation({
deprecated: true, deprecated: true,
summary: "[DEPRECATED] Legal actor registration", summary: "[DEPRECATED] Legal actor registration",
@@ -123,21 +126,24 @@ export class ActorAuthController {
} }
@Post("register/insurer") @Post("register/insurer")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: InsurerRegisterDto }) @ApiBody({ type: InsurerRegisterDto })
async registerInsurer(@Body() body: InsurerRegisterDto) { async registerInsurer(@Body() body: InsurerRegisterDto) {
return await this.actorAuthService.insurerRegister(body); return await this.actorAuthService.insurerRegister(body);
} }
/** Mock: create a field expert for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-field-expert") @Post("create-field-expert")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateFieldExpertDto }) @ApiBody({ type: CreateFieldExpertDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createFieldExpert(@Body() body: CreateFieldExpertDto) { async createFieldExpert(@Body() body: CreateFieldExpertDto) {
return await this.actorAuthService.createFieldExpertMock(body); return await this.actorAuthService.createFieldExpertMock(body);
} }
/** Mock: create a registrar for testing. Make private later. */ /** Requires super-admin token. */
@Post("create-registrar") @Post("create-registrar")
@UseGuards(SuperAdminGuard)
@ApiBody({ type: CreateRegistrarDto }) @ApiBody({ type: CreateRegistrarDto })
@ApiAcceptedResponse() @ApiAcceptedResponse()
async createRegistrar(@Body() body: CreateRegistrarDto) { async createRegistrar(@Body() body: CreateRegistrarDto) {

View File

@@ -31,6 +31,7 @@ import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service"; import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
import { HashService } from "src/utils/hash/hash.service"; import { HashService } from "src/utils/hash/hash.service";
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
function pick(obj: Record<string, any>, keys: string[]) { function pick(obj: Record<string, any>, keys: string[]) {
const out: Record<string, any> = {}; const out: Record<string, any> = {};
@@ -56,6 +57,7 @@ export class ActorAuthService {
private readonly captchaChallengeService: CaptchaChallengeService, private readonly captchaChallengeService: CaptchaChallengeService,
private readonly fileMakerDbService: FileMakerDbService, private readonly fileMakerDbService: FileMakerDbService,
private readonly fileReviewerDbService: FileReviewerDbService, private readonly fileReviewerDbService: FileReviewerDbService,
private readonly superAdminDbService: SuperAdminDbService,
) {} ) {}
// TODO convrt to class for dynamic controller // TODO convrt to class for dynamic controller
@@ -114,6 +116,13 @@ export class ActorAuthService {
res = res =
await this.fileReviewerDbService.findByLoginIdentifier(username); await this.fileReviewerDbService.findByLoginIdentifier(username);
break; break;
case RoleEnum.SUPER_ADMIN:
if (username == null && userId)
res = await this.superAdminDbService.findOne({
_id: new Types.ObjectId(userId),
});
else res = await this.superAdminDbService.findByLoginIdentifier(username);
break;
default: default:
return null; return null;
} }

View File

@@ -29,6 +29,11 @@ import { UsersModule } from "src/users/users.module";
import { HashModule } from "src/utils/hash/hash.module"; import { HashModule } from "src/utils/hash/hash.module";
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
import { CaptchaModule } from "src/captcha/captcha.module"; import { CaptchaModule } from "src/captcha/captcha.module";
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
import {
SuperAdminModel,
SuperAdminSchema,
} from "src/super-admin/entities/schema/super-admin.schema";
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */ /** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
@Global() @Global()
@@ -47,6 +52,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
schema: ClaimRequestManagementSchema, schema: ClaimRequestManagementSchema,
}, },
{ name: ClaimCase.name, schema: ClaimCaseSchema }, { name: ClaimCase.name, schema: ClaimCaseSchema },
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
]), ]),
JwtModule.register({ JwtModule.register({
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
@@ -61,6 +67,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService, JwtService,
LocalActorAuthGuard, LocalActorAuthGuard,
LocalUserAuthGuard, LocalUserAuthGuard,
SuperAdminDbService,
], ],
exports: [ exports: [
UserAuthService, UserAuthService,
@@ -68,6 +75,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
JwtService, JwtService,
LocalActorAuthGuard, LocalActorAuthGuard,
LocalUserAuthGuard, LocalUserAuthGuard,
SuperAdminDbService,
], ],
controllers: [UserAuthController, ActorAuthController], controllers: [UserAuthController, ActorAuthController],
}) })

View File

@@ -45,6 +45,7 @@ export class LocalActorAuthGuard implements CanActivate {
RoleEnum.REGISTRAR, RoleEnum.REGISTRAR,
RoleEnum.FILE_MAKER, RoleEnum.FILE_MAKER,
RoleEnum.FILE_REVIEWER, RoleEnum.FILE_REVIEWER,
RoleEnum.SUPER_ADMIN,
].includes(payload.role) ].includes(payload.role)
) { ) {
throw new UnauthorizedException("User role is not authorized"); throw new UnauthorizedException("User role is not authorized");

View File

@@ -34,12 +34,17 @@ export class CaptchaChallengeService {
this.captchaService.normalizeAnswer(generated.text), this.captchaService.normalizeAnswer(generated.text),
); );
// expireAt is the MongoDB TTL sentinel. The TTL reaper fires every ~60 s, so
// setting it equal to expiresAt means Mongo can delete the document up to 60 s
// BEFORE the application-level expiry check runs — causing the intermittent
// "captchaId not found" error under load. Adding a 120 s grace buffer ensures
// the document is always present when verify() runs its own expiresAt check.
await this.captchaChallengeDbService.create({ await this.captchaChallengeDbService.create({
captchaId, captchaId,
answerHash, answerHash,
image: generated.image, image: generated.image,
expiresAt: generated.expiresAt, expiresAt: generated.expiresAt,
expireAt: new Date(generated.expiresAt), expireAt: new Date(generated.expiresAt + 120_000),
usedAt: null, usedAt: null,
}); });

View File

@@ -10,8 +10,8 @@ export class CaptchaChallenge {
@Prop({ required: true, unique: true, index: true }) @Prop({ required: true, unique: true, index: true })
captchaId: string; captchaId: string;
@Prop() @Prop({ required: true })
answerHash?: string; answerHash: string;
@Prop({ required: true }) @Prop({ required: true })
image: string; image: string;

View File

@@ -161,6 +161,7 @@ import {
getClaimCaptureProgress, getClaimCaptureProgress,
isCapturePhaseDamagedPartyDocKey, isCapturePhaseDamagedPartyDocKey,
isClaimCaptureStepComplete, isClaimCaptureStepComplete,
OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5,
} from "src/helpers/claim-capture-phase"; } from "src/helpers/claim-capture-phase";
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -6167,6 +6168,30 @@ export class ClaimRequestManagementService {
}; };
} }
// V5 flow: FileMaker approval required before fanavaran.
// Instead of submitting, hold the claim at WAITING_FOR_FILE_MAKER_APPROVAL.
if ((claimCase as any).requiresFileMakerApproval) {
await this.claimCaseDbService.findByIdAndUpdate(claimCaseId, {
$set: { status: ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL },
$push: {
history: {
type: "V5_HELD_FOR_FILE_MAKER_APPROVAL",
actor: { actorType: "system" },
timestamp: new Date(),
metadata: { claimCaseId },
},
},
});
return {
attempted: false,
submitted: false,
skipped: true,
skipReason:
"V5 flow: FileMaker approval required before Fanavaran submission. " +
"Claim is now in WAITING_FOR_FILE_MAKER_APPROVAL.",
};
}
const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase); const skipReason = await this.getFanavaranAutoSubmitSkipReason(claimCase);
if (skipReason) { if (skipReason) {
return { return {
@@ -8114,7 +8139,7 @@ export class ClaimRequestManagementService {
file: Express.Multer.File, file: Express.Multer.File,
currentUserId: string, currentUserId: string,
actor?: { sub: string; role?: string }, actor?: { sub: string; role?: string },
options?: { v3InPersonFlow?: boolean }, options?: { v3InPersonFlow?: boolean; skipMetalPlate?: boolean },
): Promise<UploadRequiredDocumentV2ResponseDto> { ): Promise<UploadRequiredDocumentV2ResponseDto> {
try { try {
const claimCase = await this.claimCaseDbService.findById(claimRequestId); const claimCase = await this.claimCaseDbService.findById(claimRequestId);
@@ -8291,12 +8316,16 @@ export class ClaimRequestManagementService {
k === body.documentKey || k === body.documentKey ||
this.isRequiredDocumentUploadedOnClaim(claimCase, k); this.isRequiredDocumentUploadedOnClaim(claimCase, k);
remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( remaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => !afterThis(k), (k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
return !afterThis(k);
},
).length; ).length;
if (remaining === 0) { if (remaining === 0) {
const progressAfterDoc = getClaimCaptureProgress(claimCase, { const progressAfterDoc = getClaimCaptureProgress(claimCase, {
assumeCapturePhaseDocKey: body.documentKey, assumeCapturePhaseDocKey: body.documentKey,
skipMetalPlate: options?.skipMetalPlate,
}); });
if ( if (
@@ -10172,9 +10201,10 @@ export class ClaimRequestManagementService {
private shapeCaptureRequirementsForV3( private shapeCaptureRequirementsForV3(
claimCase: any, claimCase: any,
_blame: any, blame: any,
base: GetCaptureRequirementsV2ResponseDto, base: GetCaptureRequirementsV2ResponseDto,
): GetCaptureRequirementsV2ResponseDto { ): GetCaptureRequirementsV2ResponseDto {
const skipMetalPlate = !!(blame as any)?.isMadeByFileMaker;
const step = claimCase.workflow?.currentStep; const step = claimCase.workflow?.currentStep;
const capturePartDone = this.claimV3StepCompleted( const capturePartDone = this.claimV3StepCompleted(
claimCase, claimCase,
@@ -10234,7 +10264,9 @@ export class ClaimRequestManagementService {
if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES) { if (step === ClaimWorkflowStep.CAPTURE_PART_DAMAGES) {
const capturePhaseDocs = base.requiredDocuments.filter( const capturePhaseDocs = base.requiredDocuments.filter(
(d) => d.preferUploadDuringCapture, (d) =>
d.preferUploadDuringCapture &&
!(skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(d.key as any)),
); );
return { return {
...base, ...base,
@@ -10274,6 +10306,7 @@ export class ClaimRequestManagementService {
); );
} }
const blame = await this.assertV3InPersonClaim(claimCase); const blame = await this.assertV3InPersonClaim(claimCase);
const skipMetalPlate = !!(blame as any).isMadeByFileMaker;
const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey); const isCaptureDoc = isCapturePhaseDamagedPartyDocKey(body.documentKey);
@@ -10282,7 +10315,7 @@ export class ClaimRequestManagementService {
this.assertV3ClaimWorkflowStep( this.assertV3ClaimWorkflowStep(
claimCase, claimCase,
ClaimWorkflowStep.CAPTURE_PART_DAMAGES, ClaimWorkflowStep.CAPTURE_PART_DAMAGES,
"Upload chassis, engine, and metal plate photos during capture after damaged-part photos and car angles.", "Upload chassis and engine photos during capture after damaged-part photos and car angles.",
); );
if ( if (
!this.claimV3StepCompleted( !this.claimV3StepCompleted(
@@ -10294,15 +10327,15 @@ export class ClaimRequestManagementService {
"Complete outer and other part selection before capture-phase vehicle documents.", "Complete outer and other part selection before capture-phase vehicle documents.",
); );
} }
const captureProgress = getClaimCaptureProgress(claimCase); const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate });
if (!captureProgress.partsComplete) { if (!captureProgress.partsComplete) {
throw new BadRequestException( throw new BadRequestException(
"Upload photos for all selected damaged parts before chassis, engine, or metal plate photos.", "Upload photos for all selected damaged parts before chassis or engine photos.",
); );
} }
if (!captureProgress.anglesComplete) { if (!captureProgress.anglesComplete) {
throw new BadRequestException( throw new BadRequestException(
"Capture all four car angles before chassis, engine, or metal plate photos.", "Capture all four car angles before chassis or engine photos.",
); );
} }
} else { } else {
@@ -10322,7 +10355,7 @@ export class ClaimRequestManagementService {
file, file,
currentUserId, currentUserId,
actor, actor,
{ v3InPersonFlow: true }, { v3InPersonFlow: true, skipMetalPlate },
); );
} }
@@ -10500,7 +10533,8 @@ export class ClaimRequestManagementService {
`Claim case with ID ${claimRequestId} not found`, `Claim case with ID ${claimRequestId} not found`,
); );
} }
await this.assertV3InPersonClaim(claimCase); const blame = await this.assertV3InPersonClaim(claimCase);
const skipMetalPlate = !!(blame as any).isMadeByFileMaker;
if (!fileDetail) { if (!fileDetail) {
throw new BadRequestException("Video file is required."); throw new BadRequestException("Video file is required.");
@@ -10520,7 +10554,7 @@ export class ClaimRequestManagementService {
); );
} }
const captureProgress = getClaimCaptureProgress(claimCase); const captureProgress = getClaimCaptureProgress(claimCase, { skipMetalPlate });
if (!captureProgress.partsComplete) { if (!captureProgress.partsComplete) {
throw new BadRequestException( throw new BadRequestException(
"Upload photos for all selected damaged parts before the walk-around video.", "Upload photos for all selected damaged parts before the walk-around video.",
@@ -10531,9 +10565,11 @@ export class ClaimRequestManagementService {
"Capture all four car angles before the walk-around video.", "Capture all four car angles before the walk-around video.",
); );
} }
if (!isClaimCaptureStepComplete(claimCase)) { if (!isClaimCaptureStepComplete(claimCase, { skipMetalPlate })) {
throw new BadRequestException( throw new BadRequestException(
"Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.", skipMetalPlate
? "Upload capture-phase vehicle documents (chassis and engine) before the walk-around video."
: "Upload capture-phase vehicle documents (chassis, engine, metal plate) before the walk-around video.",
); );
} }

View File

@@ -242,6 +242,20 @@ export class ClaimCase {
@Prop({ type: Types.ObjectId, index: true }) @Prop({ type: Types.ObjectId, index: true })
createdByRegistrarId?: Types.ObjectId; createdByRegistrarId?: Types.ObjectId;
/**
* V5 split flow: when true, the claim must be approved by the FileMaker
* who created the file before fanavaran submission is allowed.
*/
@Prop({ type: Boolean, default: false })
requiresFileMakerApproval?: boolean;
/**
* V5 split flow: ObjectId of the FileMaker who must approve this claim.
* Set when the FileReviewer uploads the blame accident video in the V5 flow.
*/
@Prop({ type: Types.ObjectId, index: true })
fileMakerApprovalActorId?: 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

@@ -1,11 +1,20 @@
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
import { import {
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody, ApiBody,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { CurrentUser } from "src/decorators/user.decorator"; import { CurrentUser } from "src/decorators/user.decorator";
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto"; import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
import { SystemSettingsService } from "src/system-settings/system-settings.service"; import { SystemSettingsService } from "src/system-settings/system-settings.service";
import { ClientService } from "./client.service"; import { ClientService } from "./client.service";
@@ -21,26 +30,35 @@ export class ClientController {
) {} ) {}
@Post() @Post()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "Create a new insurer client (super-admin only)" })
async addClient(@Body() client: ClientDto) { async addClient(@Body() client: ClientDto) {
return await this.clientService.addClient(client); return await this.clientService.addClient(client);
} }
@Get() @Get()
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClient(@CurrentUser() user) { async getClient(@CurrentUser() user) {
return await this.clientService.getClients(); return await this.clientService.getClients();
} }
@Get("list") @Get("list")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
async getClientList(@CurrentUser() user) { async getClientList(@CurrentUser() user) {
return await this.clientService.getClientList(); return await this.clientService.getClientList();
} }
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */ /** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
@Patch("external-inquiries-live") @Patch("external-inquiries-live")
@UseGuards(SuperAdminGuard)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: "Enable or disable live external inquiries", summary: "Enable or disable live external inquiries (super-admin only)",
description: description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.", "Updates `system_settings.externalApis.sandHubUseLiveApi`. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
}) })
@ApiBody({ @ApiBody({
type: SetExternalInquiriesLiveDto, type: SetExternalInquiriesLiveDto,
@@ -52,7 +70,8 @@ export class ClientController {
}, },
disableLive: { disableLive: {
summary: "Disable live inquiries (mock mode)", summary: "Disable live inquiries (mock mode)",
description: "Use mocked inquiry responses; flows continue without external connectivity.", description:
"Use mocked inquiry responses; flows continue without external connectivity.",
value: { enabled: false }, value: { enabled: false },
}, },
}, },

View File

@@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
}) })
@IsBoolean() @IsBoolean()
carOwnership: boolean; carOwnership: boolean;
@ApiProperty({
description:
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
example: false,
})
@IsBoolean()
vinChassis: boolean;
} }
export class UpdateClientExternalInquiriesDto extends PartialType( export class UpdateClientExternalInquiriesDto extends PartialType(

View File

@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
import { import {
IsIn, IsIn,
IsInt, IsInt,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
Max, Max,
@@ -102,4 +103,20 @@ export class ListQueryV2Dto {
@IsOptional() @IsOptional()
@IsIn([...LIST_FILE_TYPE_V2]) @IsIn([...LIST_FILE_TYPE_V2])
fileType?: ListFileTypeV2; fileType?: ListFileTypeV2;
@ApiPropertyOptional({
description: "Filter start date (ISO 8601). Only files created on or after this date are returned.",
example: "2025-01-01T00:00:00.000Z",
})
@IsOptional()
@IsISO8601({ strict: false })
startDate?: string;
@ApiPropertyOptional({
description: "Filter end date (ISO 8601). Only files created on or before this date are returned.",
example: "2025-12-31T23:59:59.999Z",
})
@IsOptional()
@IsISO8601({ strict: false })
endDate?: string;
} }

View File

@@ -6,6 +6,7 @@ export const EXTERNAL_INQUIRY_TYPES = [
"sheba", "sheba",
"drivingLicense", "drivingLicense",
"carOwnership", "carOwnership",
"vinChassis",
] as const; ] as const;
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number]; export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
@@ -21,6 +22,7 @@ export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
sheba: false, sheba: false,
drivingLicense: false, drivingLicense: false,
carOwnership: false, carOwnership: false,
vinChassis: false,
}; };
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>; export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;

View File

@@ -630,11 +630,21 @@ export class ExpertBlameService {
String((d as { publicId?: string }).publicId ?? ""), String((d as { publicId?: string }).publicId ?? ""),
), ),
); );
const filtered = this.filterBlameDocsByUnifiedStatus( let filtered = this.filterBlameDocsByUnifiedStatus(
visibleCases, visibleCases,
claimByPublicId, claimByPublicId,
query.unifiedStatus, query.unifiedStatus,
); );
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
filtered = filtered.filter((doc) =>
isInListDateRange(
(doc as { createdAt?: Date }).createdAt,
fromDate,
toDate,
),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
filtered, filtered,

View File

@@ -134,13 +134,16 @@ export class ClaimDetailV2ResponseDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
"Slice of `claim.evaluation` exposed to the damage expert. " + "Slice of `claim.evaluation` exposed to the damage expert. " +
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " + "`damageExpertReply` / `damageExpertReplyFinal` are returned whenever " +
"awaiting factor validation. `ownerInsurerApproval` and " + "present, regardless of the current claim status — historical assessment " +
"`ownerPricedPartsApproval` are returned whenever they exist on the " + "data remains visible once the expert has submitted it. " +
"claim — each carries `signLink` (resolved from `signDetailId`) so the " + "`ownerInsurerApproval` and `ownerPricedPartsApproval` are returned " +
"front-end can render the user signature directly. Likewise, " + "whenever they exist on the claim — each carries `signLink` (resolved " +
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " + "from `signDetailId`) so the front-end can render the user signature " +
"expose `signLink` when a user comment signature is present.", "directly. Likewise, `damageExpertReply.userComment` / " +
"`damageExpertReplyFinal.userComment` expose `signLink` when a user " +
"comment signature is present. `priceDrop` is included whenever it " +
"has been saved, not only during the expert review phase.",
}) })
evaluation?: { evaluation?: {
damageExpertReply?: unknown; damageExpertReply?: unknown;

View File

@@ -0,0 +1,25 @@
import { HttpException, HttpStatus } from "@nestjs/common";
/**
* Thrown when a well-formed request violates a business rule that cannot be
* expressed as a generic validation error. Returns HTTP 422 with a structured
* body so the frontend can branch on `errorCode` without string-matching the
* human-readable `message`.
*
* Response body shape:
* ```json
* { "errorCode": "SOME_CODE", "message": "Human-readable explanation." }
* ```
*/
export class BusinessRuleException extends HttpException {
constructor(
public readonly errorCode: string,
message: string,
) {
super({ errorCode, message }, HttpStatus.UNPROCESSABLE_ENTITY);
}
}
export const BusinessErrorCode = {
DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
} as const;

View File

@@ -12,6 +12,10 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import {
BusinessErrorCode,
BusinessRuleException,
} from "./exceptions/business-rule.exception";
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
import { Types } from "mongoose"; import { Types } from "mongoose";
import { lastValueFrom } from "rxjs"; import { lastValueFrom } from "rxjs";
@@ -384,6 +388,12 @@ export class ExpertClaimService {
claim: any, claim: any,
actor: any, actor: any,
): Promise<void> { ): Promise<void> {
// FILE_REVIEWER: by the time we reach this method the caller has already
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
// the reviewer. Skip it — the assignment check is the access proof.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
const blame = await this.loadBlameForClaim(claim); const blame = await this.loadBlameForClaim(claim);
assertClaimCaseForExpertActor(claim, actor, blame); assertClaimCaseForExpertActor(claim, actor, blame);
} }
@@ -541,6 +551,23 @@ export class ExpertClaimService {
return this.expertVehicleFromPartyVehicle(party?.vehicle); return this.expertVehicleFromPartyVehicle(party?.vehicle);
} }
/** Extract car name / model from the inquiry snapshot embedded on the claim. */
private vehicleNamesFromClaimInquiries(claim: any): {
carName?: string;
carModel?: string;
} {
// Claim copies inquiries from blame at creation time.
// Try thirdParty first, then carBody.
const mapped =
claim?.inquiries?.thirdParty?.data?.FIRST?.mapped ??
claim?.inquiries?.carBody?.data?.FIRST?.mapped ??
{};
const carName: string | undefined =
mapped.MapTypNam || mapped.SystemField || undefined;
const carModel: string | undefined = mapped.TypeField || undefined;
return { carName, carModel };
}
private vehicleForExpertFromClaimAndBlameMap( private vehicleForExpertFromClaimAndBlameMap(
claim: any, claim: any,
blameById: Map<string, any>, blameById: Map<string, any>,
@@ -554,10 +581,24 @@ export class ExpertClaimService {
| undefined { | undefined {
const cv = claim?.vehicle; const cv = claim?.vehicle;
if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) { if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) {
const carType: string | undefined = cv.carType;
// carName/carModel that match the enum value (e.g. "sedan") are invalid —
// the expert may have accidentally copied the carType value into those fields.
// Fall back to the inquiry snapshot for the real brand / variant strings.
let carName: string | undefined = cv.carName;
let carModel: string | undefined = cv.carModel;
if (
(!carName || carName === carType) ||
(!carModel || carModel === carType)
) {
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
if (!carName || carName === carType) carName = fromInquiry.carName;
if (!carModel || carModel === carType) carModel = fromInquiry.carModel;
}
return { return {
carName: cv.carName, carName,
carModel: cv.carModel, carModel,
carType: cv.carType, carType,
...((cv as any).plate ? { plate: (cv as any).plate } : {}), ...((cv as any).plate ? { plate: (cv as any).plate } : {}),
}; };
} }
@@ -2600,12 +2641,56 @@ export class ExpertClaimService {
throw new NotFoundException("Claim request not found"); throw new NotFoundException("Claim request not found");
} }
// FILE_REVIEWER: assign themselves to the linked blame (V4 flow). // FILE_REVIEWER: two-phase lock behaviour.
// This is a different path from the damage-expert lock — it operates on the // Phase 1 — blame is WAITING_FOR_FILE_REVIEWER: atomically set
// blame document and is idempotent (same reviewer can re-assign to themselves). // assignedFileReviewerId on the blame document.
// Phase 2 — blame is already past WAITING_FOR_FILE_REVIEWER (i.e. the
// reviewer finished field work and blame moved to WAITING_FOR_EXPERT):
// the reviewer now needs the standard damage-expert workflow lock
// so they can perform damage assessment. Fall through to the
// damage-expert path below; assertExpertActorOnClaim will verify
// tenant scope via the reviewer's clientKey.
if ((actor as any).role === RoleEnum.FILE_REVIEWER) { if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
if (!claim.blameRequestId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "This claim has no linked blame file.",
});
}
const reviewerBlame = await this.blameRequestDbService.findById(
String(claim.blameRequestId),
);
if (!reviewerBlame) {
throw new NotFoundException("Linked blame file not found.");
}
const blameStatus = (reviewerBlame as any).status as string;
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
// Phase 1: blame-assign path
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
} }
// Phase 2: blame is past the initial assignment step.
// Only the reviewer who was assigned during Phase 1 may proceed.
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
? String((reviewerBlame as any).assignedFileReviewerId)
: null;
if (!assignedReviewerId) {
throw new BadRequestException({
success: false,
status: "unavailable" satisfies ExpertFileAssignStatus,
message: "No reviewer has been assigned to this file yet.",
});
}
if (assignedReviewerId !== actor.sub) {
throw new ConflictException({
success: false,
status: "locked" satisfies ExpertFileAssignStatus,
message: "This file is assigned to another reviewer.",
});
}
// Assigned reviewer — fall through to the damage-expert workflow lock below.
// (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope)
}
await this.assertExpertActorOnClaim(claim, actor); await this.assertExpertActorOnClaim(claim, actor);
@@ -2982,7 +3067,8 @@ export class ExpertClaimService {
} }
if (existingResend?.fulfilledAt) { if (existingResend?.fulfilledAt) {
throw new BadRequestException( throw new BusinessRuleException(
BusinessErrorCode.DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED,
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.", "The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
); );
} }
@@ -3444,6 +3530,7 @@ export class ExpertClaimService {
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, { await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
claimStatus: ClaimStatus.NEEDS_REVISION, claimStatus: ClaimStatus.NEEDS_REVISION,
status: ClaimCaseStatus.COMPLETED,
"workflow.locked": false, "workflow.locked": false,
$unset: { $unset: {
"workflow.lockedAt": "", "workflow.lockedAt": "",
@@ -3481,7 +3568,7 @@ export class ExpertClaimService {
return { return {
claimRequestId, claimRequestId,
status: claim.status, status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.NEEDS_REVISION, claimStatus: ClaimStatus.NEEDS_REVISION,
message: "In-person visit requested. User will be notified.", message: "In-person visit requested. User will be notified.",
}; };
@@ -3712,6 +3799,12 @@ export class ExpertClaimService {
(item) => item.unifiedFileStatus === query.unifiedStatus, (item) => item.unifiedFileStatus === query.unifiedStatus,
); );
} }
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
filtered = filtered.filter((item) =>
isInListDateRange(item.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
filtered, filtered,
{ {
@@ -4174,6 +4267,7 @@ export class ExpertClaimService {
const blameIds = makerBlames.map((b) => b._id); const blameIds = makerBlames.map((b) => b._id);
const claims = (await this.claimCaseDbService.find({ const claims = (await this.claimCaseDbService.find({
blameRequestId: { $in: blameIds }, blameRequestId: { $in: blameIds },
requiresFileMakerApproval: true,
})) as any[]; })) as any[];
const blameById = new Map<string, any>( const blameById = new Map<string, any>(
@@ -4571,7 +4665,8 @@ export class ExpertClaimService {
const actorId = actor.sub; const actorId = actor.sub;
if ( if (
actor.role !== RoleEnum.FIELD_EXPERT && actor.role !== RoleEnum.FIELD_EXPERT &&
actor.role !== RoleEnum.FILE_MAKER actor.role !== RoleEnum.FILE_MAKER &&
actor.role !== RoleEnum.FILE_REVIEWER
) { ) {
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor); await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
} }
@@ -4583,7 +4678,12 @@ export class ExpertClaimService {
} }
const linkedBlame = await this.loadBlameForClaim(claim); const linkedBlame = await this.loadBlameForClaim(claim);
// FILE_REVIEWER access is validated by the role-specific gate below
// (isAssignedToMe || isOpen). Skip the generic tenant-scope assert which
// would incorrectly reject them via the initiatedByFieldExpertId path.
if (actor.role !== RoleEnum.FILE_REVIEWER) {
assertClaimCaseForExpertActor(claim, actor, linkedBlame); assertClaimCaseForExpertActor(claim, actor, linkedBlame);
}
// Variables used both in the gate block and in the detail-build section below // Variables used both in the gate block and in the detail-build section below
const isDamageExpertPhase = const isDamageExpertPhase =
@@ -4592,7 +4692,8 @@ export class ExpertClaimService {
const isFactorValidationPending = const isFactorValidationPending =
claimIsAwaitingExpertFactorValidationV2(claim); claimIsAwaitingExpertFactorValidationV2(claim);
// FileMaker: can always view their own V4 files at any status. // FileMaker: can only view V5 files (requiresFileMakerApproval: true) that they created.
// V4 files do not require FileMaker approval and must not appear in their panel.
if (actor.role === RoleEnum.FILE_MAKER) { if (actor.role === RoleEnum.FILE_MAKER) {
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame); const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
if (!isOwn) { if (!isOwn) {
@@ -4600,6 +4701,11 @@ export class ExpertClaimService {
"FileMakers can only view files they created.", "FileMakers can only view files they created.",
); );
} }
if (!(claim as any).requiresFileMakerApproval) {
throw new ForbiddenException(
"This file does not require FileMaker approval.",
);
}
// Fall through to the detail build below // Fall through to the detail build below
} else if ( } else if (
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims // Field experts and FileReviewers can view IN_PERSON expert-initiated claims
@@ -4608,15 +4714,15 @@ export class ExpertClaimService {
actor.role === RoleEnum.FILE_REVIEWER actor.role === RoleEnum.FILE_REVIEWER
) { ) {
if (actor.role === RoleEnum.FILE_REVIEWER) { if (actor.role === RoleEnum.FILE_REVIEWER) {
// FILE_REVIEWER: must be a V4 file AND (they are the assigned reviewer OR // FILE_REVIEWER: must be a V4/V5 file AND (they are the assigned reviewer OR
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet). // the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
const isV4Blame = const isV4V5Blame =
(linkedBlame as any)?.isMadeByFileMaker && (linkedBlame as any)?.isMadeByFileMaker &&
linkedBlame?.expertInitiated && linkedBlame?.expertInitiated &&
linkedBlame?.creationMethod === "IN_PERSON"; linkedBlame?.creationMethod === "IN_PERSON";
if (!isV4Blame) { if (!isV4V5Blame) {
throw new ForbiddenException( throw new ForbiddenException(
"FileReviewers can only access V4 FileMaker files.", "FileReviewers can only access V4/V5 FileMaker files.",
); );
} }
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
@@ -4801,6 +4907,24 @@ export class ExpertClaimService {
if (fromBlame) vehiclePayload = fromBlame; if (fromBlame) vehiclePayload = fromBlame;
} }
// Patch up carName/carModel when they equal carType (invalid — expert copied the enum value).
if (vehiclePayload) {
const carType: string | undefined = vehiclePayload.carType;
if (
carType &&
((!vehiclePayload.carName || vehiclePayload.carName === carType) ||
(!vehiclePayload.carModel || vehiclePayload.carModel === carType))
) {
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
if (!vehiclePayload.carName || vehiclePayload.carName === carType) {
vehiclePayload = { ...vehiclePayload, carName: fromInquiry.carName };
}
if (!vehiclePayload.carModel || vehiclePayload.carModel === carType) {
vehiclePayload = { ...vehiclePayload, carModel: fromInquiry.carModel };
}
}
}
const blameFileContext = blameLean const blameFileContext = blameLean
? this.blameFileContextForExpert(blameLean) ? this.blameFileContextForExpert(blameLean)
: {}; : {};
@@ -4853,7 +4977,11 @@ export class ExpertClaimService {
let evaluationForApi: Record<string, unknown> | undefined; let evaluationForApi: Record<string, unknown> | undefined;
if (enrichedEvaluation) { if (enrichedEvaluation) {
evaluationForApi = {}; evaluationForApi = {};
if (isFactorValidationPending) { // damageExpertReply / damageExpertReplyFinal: include whenever present.
// Previously these were gated on isFactorValidationPending, which hid the
// expert assessment once the claim moved to user-side statuses such as
// INSURER_REVIEW_AWAITING_OWNER_SIGN. Historical assessment data should
// always be visible once submitted.
if (enrichedEvaluation.damageExpertReply !== undefined) { if (enrichedEvaluation.damageExpertReply !== undefined) {
evaluationForApi.damageExpertReply = evaluationForApi.damageExpertReply =
enrichedEvaluation.damageExpertReply; enrichedEvaluation.damageExpertReply;
@@ -4862,7 +4990,6 @@ export class ExpertClaimService {
evaluationForApi.damageExpertReplyFinal = evaluationForApi.damageExpertReplyFinal =
enrichedEvaluation.damageExpertReplyFinal; enrichedEvaluation.damageExpertReplyFinal;
} }
}
if (enrichedEvaluation.ownerInsurerApproval !== undefined) { if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
evaluationForApi.ownerInsurerApproval = evaluationForApi.ownerInsurerApproval =
enrichedEvaluation.ownerInsurerApproval; enrichedEvaluation.ownerInsurerApproval;
@@ -4871,7 +4998,8 @@ export class ExpertClaimService {
evaluationForApi.ownerPricedPartsApproval = evaluationForApi.ownerPricedPartsApproval =
enrichedEvaluation.ownerPricedPartsApproval; enrichedEvaluation.ownerPricedPartsApproval;
} }
if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) { // priceDrop: include whenever present, not only during the expert phase.
if (enrichedEvaluation.priceDrop !== undefined) {
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop; evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
} }
if (Object.keys(evaluationForApi).length === 0) { if (Object.keys(evaluationForApi).length === 0) {

View File

@@ -153,7 +153,7 @@ export class ExpertClaimV2Controller {
@ApiOperation({ @ApiOperation({
summary: "Get claim request detail for damage expert", summary: "Get claim request detail for damage expert",
description: description:
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.", "Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
async getClaimDetailV2( async getClaimDetailV2(
@@ -281,7 +281,20 @@ export class ExpertClaimV2Controller {
description: description:
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " + "Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" + "Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.", "**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **422** with `errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED`—the expert may only submit a priced reply or request in-person visit afterward.",
})
@ApiResponse({
status: 422,
description:
"Business rule violation: resend limit exceeded. " +
"`errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED` — the owner has already fulfilled a prior resend request for this claim.",
schema: {
example: {
errorCode: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
message:
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
},
},
}) })
@ApiParam({ name: "claimRequestId" }) @ApiParam({ name: "claimRequestId" })
@ApiBody({ type: ClaimSubmitResendV2Dto }) @ApiBody({ type: ClaimSubmitResendV2Dto })

View File

@@ -99,20 +99,32 @@ export class ExpertInsurerController {
@Put("branches/:branchId/status") @Put("branches/:branchId/status")
@ApiParam({ name: "branchId" }) @ApiParam({ name: "branchId" })
@ApiQuery({ name: "isActive", type: Boolean }) @ApiBody({
schema: {
type: "object",
properties: { isActive: { type: "boolean" } },
required: ["isActive"],
},
})
async setBranchStatus( async setBranchStatus(
@CurrentUser() insurer, @CurrentUser() insurer,
@Param("branchId") branchId: string, @Param("branchId") branchId: string,
@Query("isActive") isActive: string, @Body("isActive") isActive: unknown,
) { ) {
if (!insurer) { if (!insurer) {
throw new UnauthorizedException("Could not identify the current user."); throw new UnauthorizedException("Could not identify the current user.");
} }
const normalized = String(isActive).trim().toLowerCase(); // Accept native boolean (JSON body) or string coercion (legacy query/form usage)
let active: boolean;
if (typeof isActive === "boolean") {
active = isActive;
} else {
const normalized = String(isActive ?? "").trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
throw new BadRequestException("isActive must be true/false"); throw new BadRequestException("isActive must be a boolean");
}
active = ["true", "1", "yes"].includes(normalized);
} }
const active = ["true", "1", "yes"].includes(normalized);
return this.expertInsurerService.setBranchActive( return this.expertInsurerService.setBranchActive(
insurer.clientKey, insurer.clientKey,
branchId, branchId,

View File

@@ -1334,6 +1334,12 @@ export class ExpertInsurerService {
(f) => f.unifiedFileStatus === query.unifiedStatus, (f) => f.unifiedFileStatus === query.unifiedStatus,
); );
} }
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
if (fromDate || toDate) {
files = files.filter((f) =>
isInListDateRange(f.createdAt, fromDate, toDate),
);
}
const paged = applyListQueryV2( const paged = applyListQueryV2(
files, files,

View File

@@ -16,6 +16,12 @@ export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
"damaged_metal_plate", "damaged_metal_plate",
] as const; ] as const;
/** Metal-plate keys that are optional in the V4/V5 FileMaker flow. */
export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
"damaged_metal_plate",
"guilty_metal_plate",
] as const;
export type CapturePhaseSequence = export type CapturePhaseSequence =
| "parts" | "parts"
| "angles" | "angles"
@@ -52,7 +58,7 @@ function isRequiredDocumentUploadedOnClaim(
export function getClaimCaptureProgress( export function getClaimCaptureProgress(
claimCase: any, claimCase: any,
options?: { assumeCapturePhaseDocKey?: string }, options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean },
): ClaimCaptureProgress { ): ClaimCaptureProgress {
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined; const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
const selectedNorm = normalizeDamageSelectedParts( const selectedNorm = normalizeDamageSelectedParts(
@@ -83,6 +89,7 @@ export function getClaimCaptureProgress(
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter( const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
(k) => { (k) => {
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
if (k === options?.assumeCapturePhaseDocKey) return false; if (k === options?.assumeCapturePhaseDocKey) return false;
return !isRequiredDocumentUploadedOnClaim(claimCase, k); return !isRequiredDocumentUploadedOnClaim(claimCase, k);
}, },
@@ -111,8 +118,11 @@ export function getClaimCaptureProgress(
}; };
} }
export function isClaimCaptureStepComplete(claimCase: any): boolean { export function isClaimCaptureStepComplete(
const p = getClaimCaptureProgress(claimCase); claimCase: any,
options?: { skipMetalPlate?: boolean },
): boolean {
const p = getClaimCaptureProgress(claimCase, options);
return ( return (
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
); );

View File

@@ -263,8 +263,6 @@ export function buildClaimDetailsV2OwnerGuidance(
shape.mixedFactorAndPrice && shape.mixedFactorAndPrice &&
!claim.evaluation?.ownerPricedPartsApproval?.signedAt !claim.evaluation?.ownerPricedPartsApproval?.signedAt
) { ) {
const ow = objectionWindowForOwner(claim);
const allowObj = ow.pricingEligible;
return { return {
phaseKey: "SIGN_PRICED_LINES", phaseKey: "SIGN_PRICED_LINES",
headline: "Sign acceptance of priced lines", headline: "Sign acceptance of priced lines",
@@ -278,13 +276,12 @@ export function buildClaimDetailsV2OwnerGuidance(
"Multipart sign + agree + branchId", "Multipart sign + agree + branchId",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */ /** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return { return {
phaseKey: "INSURER_REVIEW_NEEDS_REVISION", phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
headline: "Insurer approval — action pending", headline: "Insurer approval — action pending",
@@ -298,13 +295,12 @@ export function buildClaimDetailsV2OwnerGuidance(
"Sign if prompted by app state", "Sign if prompted by app state",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) { if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
const allowObj = objectionWindowForOwner(claim).pricingEligible;
return { return {
phaseKey: "FINAL_SIGN_OR_REJECT", phaseKey: "FINAL_SIGN_OR_REJECT",
headline: "Accept or reject final pricing", headline: "Accept or reject final pricing",
@@ -323,8 +319,8 @@ export function buildClaimDetailsV2OwnerGuidance(
"Dispute priced lines before signing", "Dispute priced lines before signing",
), ),
], ],
objectionAllowed: allowObj, objectionAllowed,
objectionHint: objectionHintWhen(allowObj), objectionHint,
}; };
} }
} }

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
@@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 {
imGuilty?: boolean; imGuilty?: boolean;
} }
/**
* V2 initial-form step submitted with a VIN/chassis number instead of a plate.
* All identity and license fields from {@link AddPlateDto} are preserved; only
* `plate` is replaced by `vin` (the 17-character chassis / VIN string).
*/
export class InitialFormVinDto {
@ApiProperty({
type: String,
required: true,
description: "17-character VIN / chassis number (شماره شاسی)",
example: "NAAM01E15HK123456",
maxLength: 17,
})
@IsString()
@IsNotEmpty()
@MaxLength(17)
vin: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfInsurer: string;
@ApiProperty({ type: String, required: true })
nationalCodeOfDriver: string;
@ApiProperty({ type: String, required: true })
insurerLicense: string;
@ApiProperty({ type: String, required: true })
driverLicense: string;
@ApiProperty({ type: Boolean, required: true })
driverIsInsurer: boolean;
@ApiProperty({ type: Boolean, required: true, default: false })
isNewCar: boolean;
@ApiProperty({ type: Boolean, required: true })
userNoCertificate: boolean;
@ApiProperty({ type: Number, required: true })
insurerBirthday: number;
@ApiPropertyOptional({ type: String, required: false })
driverBirthday: string | null;
}
// export class DocsOfThisFile { // export class DocsOfThisFile {
// @ApiProperty() // @ApiProperty()
// firstPartyFile?: FirstPartyFileDto; // firstPartyFile?: FirstPartyFileDto;

View File

@@ -0,0 +1,390 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Post,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
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 { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { CreationMethod } from "./entities/schema/request-management.schema";
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto";
import {
CarBodyFormDto,
DescriptionDto,
LocationDto,
} from "./dto/create-request-management.dto";
import { RequestManagementService } from "./request-management.service";
import { PartyRole } from "./entities/schema/partyRole.enum";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
*
* The only behavioural difference in the V5 flow is in the FileReviewer's final
* step: instead of moving directly to WAITING_FOR_EXPERT, the blame video moves
* the file to WAITING_FOR_FINANCIAL_EXPERT so a FinancialExpert can approve or
* reject before fanavaran submission.
*
* The FileMaker side is unchanged; all sequence and endpoints are the same.
*
* THIRD_PARTY sequence:
* create → send-party-otp (guilty) → verify-party-otp (guilty)
* → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim)
* → add-detail-location / add-detail-description / upload-voice (guilty)
* → sign (guilty)
* → send-party-otp (damaged) → verify-party-otp (damaged)
* → run-inquiries (damaged)
* → add-detail-location / add-detail-description / upload-voice (damaged)
* → sign (damaged) ← FileMaker job done here
*/
@ApiTags("v5 FileMaker — blame file creation (with FinancialExpert approval)")
@Controller("v5/file-maker/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_MAKER)
export class FileMakerBlameV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
// ─── File creation ───────────────────────────────────────────────────────────
@Post()
@ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker V5)" })
@ApiBody({ type: CreateExpertInitiatedFileDto })
async create(
@CurrentUser() fileMaker: any,
@Body() dto: CreateExpertInitiatedFileDto,
) {
return this.requestManagementService.createExpertInitiatedBlameV2(
fileMaker,
{ ...dto, creationMethod: CreationMethod.IN_PERSON },
);
}
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during guilty-party run-inquiries. " +
"Share this claim ID with the FileReviewer.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileMaker,
requestId,
);
}
// ─── OTP ─────────────────────────────────────────────────────────────────────
@Post("send-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: SendPartyOtpDto })
@ApiOperation({
summary: "Send OTP to one party",
description:
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
})
async sendPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: SendPartyOtpDto,
) {
return this.requestManagementService.sendPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
@Post("verify-party-otp/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: VerifyPartyOtpDto })
@ApiOperation({
summary: "Verify one party OTP",
description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.",
})
async verifyPartyOtp(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: VerifyPartyOtpDto,
) {
return this.requestManagementService.verifyPartyOtpV2(
fileMaker,
requestId,
dto,
);
}
// ─── Pre-inquiry form ────────────────────────────────────────────────────────
@Post("car-body-form/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: CarBodyFormDto })
@ApiOperation({
summary: "CAR_BODY only — accident type before inquiries",
description: "Submit after guilty-party OTP verify, before run-inquiries.",
})
async carBodyForm(
@Param("requestId") requestId: string,
@Body() body: CarBodyFormDto,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.carBodyAccidentTypeFormV3(
requestId,
body,
fileMaker,
);
}
@Post("run-inquiries/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: RunInquiriesV3Dto })
@ApiOperation({
summary: "Run external inquiries for the current party",
description:
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
})
async runInquiries(
@CurrentUser() fileMaker: any,
@Param("requestId") requestId: string,
@Body() dto: RunInquiriesV3Dto,
) {
return this.requestManagementService.runInquiriesV3(
requestId,
fileMaker,
dto,
);
}
// ─── Party details ────────────────────────────────────────────────────────────
@Post("add-detail-location/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto })
@ApiOperation({
summary: "Add location for current party (partyRole FIRST or SECOND)",
})
async addLocation(
@Param("requestId") requestId: string,
@Body() body: LocationDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyLocationV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@Post("add-detail-description/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: DescriptionDto })
@ApiOperation({ summary: "Add description for current party" })
async addDescription(
@Param("requestId") requestId: string,
@Body() body: DescriptionDto,
@CurrentUser() fileMaker: any,
@Body("partyRole") partyRole?: string,
) {
return this.requestManagementService.addPartyDescriptionV3(
requestId,
fileMaker,
body,
partyRole,
);
}
@ApiBody({
schema: {
type: "object",
properties: {
file: { type: "string", format: "binary" },
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
},
required: ["file"],
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/voice",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-filemaker-voice-${unique}${ex}`);
},
}),
}),
)
@Post("upload-voice/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({ summary: "Upload voice for current party" })
async uploadVoice(
@Param("requestId") requestId: string,
@CurrentUser() fileMaker: any,
@UploadedFile() voice: Express.Multer.File,
@Body("partyRole") partyRole?: string,
) {
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
return this.requestManagementService.addPartyVoiceV3(
requestId,
fileMaker,
voice,
partyRole,
);
}
// ─── Signatures (final FileMaker step) ───────────────────────────────────────
@Put("sign/:requestId")
@ApiParam({ name: "requestId" })
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Party on-site signature (FileMaker final step)",
description:
"Collect FIRST (guilty) then SECOND (damaged) signatures. " +
"After the last signature the file is ready for FileReviewer pickup.",
})
@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: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/signs",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-filemaker-party-${unique}${ex}`);
},
}),
}),
)
async sign(
@Param("requestId") requestId: string,
@Body() body: { partyRole?: string; isAccept?: string | boolean },
@CurrentUser() fileMaker: any,
@UploadedFile() sign: Express.Multer.File,
) {
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
const partyRole =
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
return this.requestManagementService.expertUploadPartySignatureV3(
fileMaker,
requestId,
partyRole,
isAccept,
sign,
);
}
// ─── Claim document upload ────────────────────────────────────────────────────
@Post("upload-document/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Upload one required claim document (FileMaker)",
description:
"Upload licenses and car cards against the auto-created claim. " +
"Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileMaker: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileMaker.sub,
fileMaker,
);
}
@Get("capture-requirements/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Capture requirements (step-aware)",
description:
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
})
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileMaker.sub,
fileMaker,
);
}
}

View File

@@ -0,0 +1,109 @@
import {
Body,
Controller,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { ApiPropertyOptional } 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 { RequestManagementService } from "./request-management.service";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
class FileMakerRejectDto {
@ApiPropertyOptional({
description: "Reason for rejection (visible to FileReviewer)",
example: "Damage assessment is incorrect — please re-evaluate parts X and Y",
})
@IsOptional()
@IsString()
reason?: string;
}
/**
* V5 FileMaker approval panel.
*
* After the full claim flow completes (FileReviewer does damage assessment via
* expert-claim APIs, user signs), the claim lands in WAITING_FOR_FILE_MAKER_APPROVAL.
* The FileMaker who created the blame file can then:
*
* approve → triggers fanavaran submission (claim → COMPLETED)
* reject → sends claim back to WAITING_FOR_DAMAGE_EXPERT so the FileReviewer
* can re-lock, adjust pricing, and redo the user interaction
*
* All endpoints operate on `claimRequestId` (the claim case ID, not the blame ID).
* Use `GET v5/file-maker/blame-request-management/claim-id/:requestId` to obtain
* the claimRequestId from the original blame request ID.
*/
@ApiTags("v5 FileMaker — claim approval before fanavaran")
@Controller("v5/file-maker/claim-approval")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_MAKER)
export class FileMakerClaimApprovalV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
) {}
@Post("approve/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Approve the completed claim (FileMaker V5)",
description:
"Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
"Marks the claim COMPLETED and triggers fanavaran submission.",
})
async approve(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileMaker: any,
) {
const result = await this.requestManagementService.fileMakerApproveV5(
fileMaker,
claimRequestId,
);
// Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate
// (requiresFileMakerApproval) has been cleared.
const fanavaran =
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
claimRequestId,
);
return { ...result, fanavaran };
}
@Post("reject/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: FileMakerRejectDto })
@ApiOperation({
summary: "Reject the completed claim back to FileReviewer (FileMaker V5)",
description:
"Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
"Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " +
"adjust the damage assessment, and restart user interaction as needed.",
})
async reject(
@Param("claimRequestId") claimRequestId: string,
@Body() body: FileMakerRejectDto,
@CurrentUser() fileMaker: any,
) {
return this.requestManagementService.fileMakerRejectV5(
fileMaker,
claimRequestId,
body?.reason,
);
}
}

View File

@@ -364,7 +364,6 @@ export class FileReviewerBlameV4Controller {
@ApiBody({ @ApiBody({
schema: { schema: {
type: "object", type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } }, properties: { file: { type: "string", format: "binary" } },
}, },
}) })
@@ -385,17 +384,19 @@ export class FileReviewerBlameV4Controller {
@Post("upload-video/:requestId") @Post("upload-video/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiOperation({ @ApiOperation({
summary: "Blame accident video (FileReviewer final step)", summary: "Blame accident video (FileReviewer final step — optional)",
description: description:
"Last step of the V4 flow. Requires claim walk-around video and completed capture. " + "Last step of the V4 flow. Requires claim walk-around video and completed capture. " +
"Sets blame status to WAITING_FOR_EXPERT.", "File is optional — omit it to complete the blame without attaching a video.",
}) })
async uploadBlameVideo( async uploadBlameVideo(
@Param("requestId") requestId: string, @Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any, @CurrentUser() fileReviewer: any,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
) { ) {
if (file) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video"); await this.mediaPolicyService.assertForBlame(file, requestId, "video");
}
return this.requestManagementService.expertUploadBlameVideoV3( return this.requestManagementService.expertUploadBlameVideoV3(
fileReviewer, fileReviewer,
requestId, requestId,

View File

@@ -0,0 +1,408 @@
import { extname } from "node:path";
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";
import { FileInterceptor } from "@nestjs/platform-express";
import { diskStorage } from "multer";
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 { MediaPolicyService } from "src/media-policy/media-policy.service";
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
import { RequestManagementService } from "./request-management.service";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import {
SelectOuterPartsV2Dto,
SelectOuterPartsV2ResponseDto,
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto";
import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto";
import {
UploadRequiredDocumentV2Dto,
UploadRequiredDocumentV2ResponseDto,
} from "src/claim-request-management/dto/upload-document-v2.dto";
import {
CapturePartV2Dto,
CapturePartV2ResponseDto,
} from "src/claim-request-management/dto/capture-part-v2.dto";
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
/**
* V5 FileReviewer flow — same as V4 except the final blame accident video moves
* the file to WAITING_FOR_FINANCIAL_EXPERT instead of WAITING_FOR_EXPERT.
*
* A FinancialExpert must then approve the file (→ WAITING_FOR_EXPERT) or
* reject it back (→ FINANCIAL_EXPERT_REJECTED) for the FileReviewer to correct.
*
* Sequence:
* accident-fields
* → capture-requirements (via linked claimRequestId)
* → upload-document (licenses, car cards)
* → select-outer-parts
* → select-other-parts
* → capture-part (damaged-part photos + four angles)
* → upload-document (chassis / engine / metal plate — capture phase)
* → car-capture (walk-around video)
* → upload-video (blame accident video — final, moves to WAITING_FOR_FINANCIAL_EXPERT)
*/
@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)")
@Controller("v5/file-reviewer/blame-request-management")
@ApiBearerAuth()
@UseGuards(LocalActorAuthGuard, RolesGuard)
@Roles(RoleEnum.FILE_REVIEWER)
export class FileReviewerBlameV5Controller {
constructor(
private readonly requestManagementService: RequestManagementService,
private readonly claimRequestManagementService: ClaimRequestManagementService,
private readonly mediaPolicyService: MediaPolicyService,
) {}
// ─── Linked claim lookup ──────────────────────────────────────────────────────
@Get("claim-id/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiOperation({
summary: "Get linked claim ID",
description:
"Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " +
"Use this claim ID for all capture/document/parts endpoints below.",
})
async getLinkedClaimId(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.getV3LinkedClaimForExpert(
fileReviewer,
requestId,
);
}
// ─── Accident fields ──────────────────────────────────────────────────────────
@Post("accident-fields/:requestId")
@ApiParam({ name: "requestId" })
@ApiBody({ type: ExpertAccidentFieldsDto })
@ApiOperation({
summary: "Accident type / fields (FileReviewer first step)",
description:
"Saves accident fields on the sealed file. " +
"Does not move to WAITING_FOR_FINANCIAL_EXPERT — that happens after the final blame accident video.",
})
async addAccidentFields(
@Param("requestId") requestId: string,
@Body() fields: ExpertAccidentFieldsDto,
@CurrentUser() fileReviewer: any,
) {
return this.requestManagementService.expertAddAccidentFieldsForBlameV3(
fileReviewer,
requestId,
fields,
);
}
// ─── Capture requirements ─────────────────────────────────────────────────────
@Get("capture-requirements/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiOperation({
summary: "Capture requirements (step-aware)",
description:
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
})
async getCaptureRequirements(
@Param("claimRequestId") claimRequestId: string,
@CurrentUser() fileReviewer: any,
): Promise<GetCaptureRequirementsV2ResponseDto> {
return this.claimRequestManagementService.getCaptureRequirementsV3(
claimRequestId,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Document upload ──────────────────────────────────────────────────────────
@Post("upload-document/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-documents",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Upload one required claim document",
description:
"Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " +
"Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.",
})
async uploadDocument(
@Param("claimRequestId") claimRequestId: string,
@Body() body: UploadRequiredDocumentV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<UploadRequiredDocumentV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.uploadRequiredDocumentV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Part selection ───────────────────────────────────────────────────────────
@Patch("select-outer-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId" })
@ApiBody({ type: SelectOuterPartsV2Dto })
@ApiOperation({ summary: "Select damaged outer parts" })
async selectOuterParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOuterPartsV2Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOuterPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOuterPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
@Patch("select-other-parts/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiOperation({
summary: "Select other damaged parts (V5)",
description:
"Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.",
})
@ApiBody({
description:
"Other parts selection. Bank info is already on the claim from run-inquiries.",
schema: {
type: "object",
properties: {
otherParts: {
oneOf: [
{
type: "array",
items: {
type: "string",
enum: [
"engine",
"suspension",
"brake_system",
"electrical",
"radiator",
"transmission",
"exhaust",
"headlight",
"taillight",
"mirror",
"glass",
],
},
},
{ type: "string", description: "JSON string array for multipart" },
],
example: ["engine", "suspension"],
},
},
},
})
async selectOtherParts(
@Param("claimRequestId") claimRequestId: string,
@Body() body: SelectOtherPartsV3Dto,
@CurrentUser() fileReviewer: any,
): Promise<SelectOtherPartsV2ResponseDto> {
return this.claimRequestManagementService.selectOtherPartsV3(
claimRequestId,
body,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Photo capture ────────────────────────────────────────────────────────────
@Post("capture-part/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/claim-captures",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-capture-${unique}${ex}`);
},
}),
}),
)
@ApiOperation({
summary: "Car angles and damaged-part photos",
description:
"CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " +
"(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.",
})
@ApiBody({
schema: {
type: "object",
required: ["captureType", "captureKey", "file"],
properties: {
captureType: {
type: "string",
enum: ["angle", "part"],
example: "angle",
},
captureKey: {
type: "string",
example: "front",
description:
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
},
file: { type: "string", format: "binary" },
},
},
})
async capturePart(
@Param("claimRequestId") claimRequestId: string,
@Body() body: CapturePartV2Dto,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
): Promise<CapturePartV2ResponseDto> {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
return this.claimRequestManagementService.capturePartV3(
claimRequestId,
body,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Walk-around video ────────────────────────────────────────────────────────
@Patch("car-capture/:claimRequestId")
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/car-capture-videos/",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-claim-video-${unique}${ex}`);
},
}),
}),
)
@ApiBody({
schema: {
type: "object",
required: ["file"],
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiOperation({
summary: "Walk-around video",
description:
"Upload after capture-part is complete (parts, angles, capture-phase docs). Required before the final blame accident video.",
})
async carCapture(
@Param("claimRequestId") claimRequestId: string,
@UploadedFile("file") file: Express.Multer.File,
@CurrentUser() fileReviewer: any,
) {
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
return this.claimRequestManagementService.setVideoCaptureV3(
claimRequestId,
file,
fileReviewer.sub,
fileReviewer,
);
}
// ─── Final blame video ─────────────────────────────────────────────────────────
//
// V5 difference: moves file to WAITING_FOR_FINANCIAL_EXPERT, not WAITING_FOR_EXPERT.
@ApiBody({
schema: {
type: "object",
properties: { file: { type: "string", format: "binary" } },
},
})
@ApiConsumes("multipart/form-data")
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
storage: diskStorage({
destination: "./files/video",
filename: (req, file, callback) => {
const unique = Date.now();
const ex = extname(file.originalname);
callback(null, `v5-blame-accident-${unique}${ex}`);
},
}),
}),
)
@Post("upload-video/:requestId")
@ApiParam({ name: "requestId" })
@ApiOperation({
summary: "Blame accident video (FileReviewer final step — V5, optional)",
description:
"Last step of the V5 FileReviewer flow. Requires claim walk-around video and completed capture. " +
"File is optional — omit it to complete the blame without attaching a video.",
})
async uploadBlameVideo(
@Param("requestId") requestId: string,
@CurrentUser() fileReviewer: any,
@UploadedFile() file: Express.Multer.File,
) {
if (file) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
}
return this.requestManagementService.expertUploadBlameVideoV5(
fileReviewer,
requestId,
file,
);
}
}

View File

@@ -367,8 +367,7 @@ export class InquiryRefreshService {
} }
next.insurance.policyNumber = next.insurance.policyNumber =
mapped.LastCompanyDocumentNumber || mapped.PrntPlcyCmpDocNo ||
mapped.insuranceNumber ||
next.insurance.policyNumber; next.insurance.policyNumber;
next.insurance.company = next.insurance.company =
mapped.companyPersianName || mapped.CompanyName || next.insurance.company; mapped.companyPersianName || mapped.CompanyName || next.insurance.company;

View File

@@ -45,6 +45,9 @@ import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.control
import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller"; import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller";
import { FileMakerBlameV4Controller } from "./file-maker-blame-v4.controller"; import { FileMakerBlameV4Controller } from "./file-maker-blame-v4.controller";
import { FileReviewerBlameV4Controller } from "./file-reviewer-blame-v4.controller"; import { FileReviewerBlameV4Controller } from "./file-reviewer-blame-v4.controller";
import { FileMakerBlameV5Controller } from "./file-maker-blame-v5.controller";
import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controller";
import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller";
import { InquiryRefreshController } from "./inquiry-refresh.controller"; import { InquiryRefreshController } from "./inquiry-refresh.controller";
import { InquiryRefreshService } from "./inquiry-refresh.service"; import { InquiryRefreshService } from "./inquiry-refresh.service";
@@ -85,6 +88,9 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
RegistrarBlameMirrorController, RegistrarBlameMirrorController,
FileMakerBlameV4Controller, FileMakerBlameV4Controller,
FileReviewerBlameV4Controller, FileReviewerBlameV4Controller,
FileMakerBlameV5Controller,
FileReviewerBlameV5Controller,
FileMakerClaimApprovalV5Controller,
InquiryRefreshController, InquiryRefreshController,
], ],
providers: [ providers: [

View File

@@ -18,6 +18,7 @@ import {
CarBodyFormDto, CarBodyFormDto,
CarBodySecondForm, CarBodySecondForm,
InitialFormDto, InitialFormDto,
InitialFormVinDto,
} from "src/request-management/dto/create-request-management.dto"; } from "src/request-management/dto/create-request-management.dto";
import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service";
import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service";
@@ -1323,7 +1324,7 @@ export class RequestManagementService {
}; };
if (!party.insurance) party.insurance = {} as any; if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.insuranceNumber; party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName; party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate; party.insurance.startDate = inquiryMapped?.IssueDate;
@@ -1475,6 +1476,313 @@ export class RequestManagementService {
} }
} }
/**
* V2 initial-form submitted with a VIN/chassis number instead of a plate.
*
* Performs:
* 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off)
* 2. Personal identity inquiry (same as the plate path)
*
* The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat`
* normaliser used by `getTejaratBlockInquiry`, so all downstream party/vehicle
* /insurance field assignments are identical.
*/
async initialFormVinV2(
requestId: string,
body: InitialFormVinDto,
user: any,
expectedRole?: string,
) {
try {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
if (!req.workflow?.currentStep) {
throw new BadRequestException("Request workflow is not initialized");
}
const stepKey = req.workflow.currentStep as WorkflowStep;
if (
stepKey !== WorkflowStep.FIRST_INITIAL_FORM &&
stepKey !== WorkflowStep.SECOND_INITIAL_FORM
) {
throw new BadRequestException(
`Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`,
);
}
const role = this.stepKeyToPartyRole(stepKey);
const idx = this.getPartyIndex(req, role);
if (idx === -1)
throw new BadRequestException(`${role} party not found on request`);
const party = req.parties[idx];
this.assertPartyOwner(
party,
user,
`Only ${role} party can submit this step`,
req,
);
this.assertExpectedPartyRole(
expectedRole,
role,
this.isBlameOnBehalfActor(req, user),
);
if (!party.person) party.person = {} as any;
if (
!party.person.userId &&
user?.sub &&
!this.isBlameOnBehalfActor(req, user)
) {
party.person.userId = Types.ObjectId.isValid(user.sub)
? new Types.ObjectId(user.sub)
: undefined;
}
// Validation: driver/insurer sameness rules (identical to plate path)
if (body.driverIsInsurer === false) {
if (
body.nationalCodeOfInsurer === body.nationalCodeOfDriver ||
body.insurerLicense === body.driverLicense
) {
throw new BadRequestException(
"Insurer and Driver should be two different persons in this mode.",
);
}
} else if (body.driverIsInsurer === true) {
const sameNat =
body.nationalCodeOfInsurer === body.nationalCodeOfDriver;
const sameLic = body.insurerLicense === body.driverLicense;
const sameBirthday =
body.driverBirthday == null ||
String(body.driverBirthday) === String(body.insurerBirthday);
if (!sameNat || !sameLic || !sameBirthday) {
throw new BadRequestException(
"When driverIsInsurer is true, insurer and driver data must be the same.",
);
}
}
// ---- External inquiry: ESG policyByChassis ----
const inquiryClientId = party.person?.clientId
? String(party.person.clientId)
: undefined;
const inquiryOptions = inquiryClientId
? { clientId: inquiryClientId }
: undefined;
let inquiryRaw: any;
let inquiryMapped: any;
try {
const inquiry = await this.sandHubService.getPolicyByChassisInquiry(
body.vin,
inquiryOptions,
);
inquiryRaw = inquiry.raw;
inquiryMapped = inquiry.mapped;
this.logger.log(
`[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`,
);
this.logger.log(
`[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
} catch (err: any) {
this.logger.error(
`[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`,
);
if (err?.response) {
this.logger.error(
`[ESG] policyByChassis response for request=${req._id}: status=${
err.response.status
}, data=${JSON.stringify(err.response.data)}`,
);
}
this.recordPartyCaseInquiryStatus(
req,
"thirdParty",
role,
false,
{},
err,
);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST);
}
if (inquiryMapped?.Error) {
this.logger.warn(
`[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`,
);
this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
});
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
inquiryMapped.Error.Message || "VIN inquiry returned error",
HttpStatus.BAD_REQUEST,
);
}
// ---- External inquiry 2: personal identity (same as plate path) ----
const personalNationalCode =
body.nationalCodeOfInsurer || body.nationalCodeOfDriver;
const personalBirthDate: number | string | null = (body.insurerBirthday ??
body.driverBirthday ??
null) as number | string | null;
if (
!personalNationalCode ||
personalBirthDate === null ||
personalBirthDate === undefined ||
String(personalBirthDate).trim() === ""
) {
throw new BadRequestException(
"Valid nationalCode and birthDate are required for personal inquiry.",
);
}
try {
const personalInquiry = await this.sandHubService.getPersonalInquiry(
personalNationalCode,
personalBirthDate,
inquiryOptions,
);
this.recordPartyCaseInquiryStatus(
req,
"person",
role,
true,
personalInquiry,
);
this.logger.log(
`[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(personalInquiry)}`,
);
} catch (err: any) {
this.logger.error(
`[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`,
);
this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err);
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: { inquiries: req.inquiries },
});
throw new HttpException(
"Personal identity inquiry failed",
HttpStatus.BAD_REQUEST,
);
}
// Resolve insurer client from inquiry response
const clientName = inquiryMapped?.CompanyName;
if (!clientName) {
throw new BadRequestException(
"CompanyName missing from VIN inquiry response",
);
}
const companyCode = inquiryMapped?.CompanyCode;
const client = await this.clientService.findOrCreateClientByCompanyCode(
companyCode,
clientName,
);
if (!client) {
throw new BadRequestException(
"CompanyCode missing or invalid in VIN inquiry response",
);
}
// Persist party data
const resolvedClientId =
(client as any)?._id ?? (client as any)?._doc?._id;
party.person.clientId = resolvedClientId;
party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer;
party.person.nationalCodeOfDriver = body.nationalCodeOfDriver;
party.person.insurerLicense = body.insurerLicense;
party.person.driverLicense = body.driverLicense;
party.person.driverIsInsurer = body.driverIsInsurer;
party.person.isNewCar = body.isNewCar;
party.person.userNoCertificate = body.userNoCertificate;
party.person.insurerBirthday = body.insurerBirthday;
party.person.driverBirthday =
body.driverBirthday ??
(body.driverIsInsurer ? String(body.insurerBirthday) : null);
if (!party.vehicle) party.vehicle = {} as any;
party.vehicle.isNew = body.isNewCar;
// Store VIN as the vehicle identifier instead of plate
party.vehicle.plateId = body.vin;
party.vehicle.name = inquiryMapped?.MapTypNam;
party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`;
party.vehicle.inquiry = {
source: "ESG_VIN_INQUIRY",
raw: inquiryRaw,
mapped: inquiryMapped,
};
if (!party.insurance) party.insurance = {} as any;
party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo;
party.insurance.company = clientName;
party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl;
party.insurance.startDate = inquiryMapped?.IssueDate;
party.insurance.endDate = inquiryMapped?.EndDate;
// Advance workflow
await this.advanceWorkflowToNext(req, stepKey);
const historyEntry: any = {
type: `${stepKey}_SUBMITTED`,
actor: {
actorId: Types.ObjectId.isValid(user?.sub)
? new Types.ObjectId(user.sub)
: undefined,
actorName: user?.fullName,
actorType: "user",
},
metadata: {
role,
companyCode,
companyName: clientName,
inquiryType: "VIN",
vin: body.vin,
},
};
await this.blameRequestDbService.findByIdAndUpdate(req._id, {
$set: {
[`parties.${idx}.person`]: party.person,
[`parties.${idx}.vehicle`]: party.vehicle,
[`parties.${idx}.insurance`]: party.insurance,
inquiries: req.inquiries,
"workflow.completedSteps": req.workflow.completedSteps,
"workflow.currentStep": req.workflow.currentStep,
"workflow.nextStep": req.workflow.nextStep,
status: req.status,
},
$push: { history: historyEntry },
});
return {
requestId: req._id,
publicId: req.publicId,
workflow: req.workflow,
blameStatus: req.blameStatus,
status: req.status,
};
} catch (error) {
throw error;
}
}
async addDetailLocationV2( async addDetailLocationV2(
requestId: string, requestId: string,
body: LocationDto, body: LocationDto,
@@ -4150,13 +4458,13 @@ export class RequestManagementService {
/** /**
* Verify the current user (field expert) can access this BlameRequest (v2). * Verify the current user (field expert) can access this BlameRequest (v2).
*/ */
private verifyExpertAccessForBlameV2(req: any, expert: any): void { private async verifyExpertAccessForBlameV2(req: any, expert: any): Promise<void> {
// FILE_REVIEWER can access V4 FileMaker-sealed files. // FILE_REVIEWER can access V4/V5 FileMaker-sealed files.
// They must be the assigned reviewer (or the file is still open for taking). // They must be the assigned reviewer (or the file is still open for taking).
if (expert?.role === RoleEnum.FILE_REVIEWER) { if (expert?.role === RoleEnum.FILE_REVIEWER) {
if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) { if (!req?.isMadeByFileMaker || !req?.expertInitiated || req?.creationMethod !== CreationMethod.IN_PERSON) {
throw new ForbiddenException( throw new ForbiddenException(
"FileReviewer can only access V4 FileMaker files.", "FileReviewer can only access V4/V5 FileMaker files.",
); );
} }
if ( if (
@@ -4168,7 +4476,12 @@ export class RequestManagementService {
"This file has not been sealed by a FileMaker yet.", "This file has not been sealed by a FileMaker yet.",
); );
} }
// Enforce reviewer assignment: must be assigned to them or open (no reviewer yet) // Enforce reviewer assignment: must be assigned to them or open (no reviewer yet).
// If the file is open (no assignedFileReviewerId) we atomically claim it now —
// this handles the case where a reviewer skipped the explicit lock step and went
// straight to working on the file (e.g. accident-fields before calling the lock
// endpoint). Without this, assignedFileReviewerId is never written and the file
// disappears from the reviewer's list after the blame moves to WAITING_FOR_EXPERT.
const assignedId = req.assignedFileReviewerId const assignedId = req.assignedFileReviewerId
? String(req.assignedFileReviewerId) ? String(req.assignedFileReviewerId)
: null; : null;
@@ -4177,6 +4490,21 @@ export class RequestManagementService {
"This file has been taken by another FileReviewer.", "This file has been taken by another FileReviewer.",
); );
} }
if (!assignedId) {
// Atomically claim — ignore if another reviewer won the race (they would have
// been caught by the assignedId check above on their own first call).
await this.blameRequestDbService.findOneAndUpdate(
{
_id: req._id,
$or: [
{ assignedFileReviewerId: { $exists: false } },
{ assignedFileReviewerId: null },
],
},
{ $set: { assignedFileReviewerId: new Types.ObjectId(String(expert.sub)) } },
{ new: false },
);
}
return; return;
} }
if (req?.expertInitiated && req?.initiatedByFieldExpertId) { if (req?.expertInitiated && req?.initiatedByFieldExpertId) {
@@ -4525,7 +4853,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated LINK blame files.", "This endpoint is only for expert-initiated LINK blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const phone = (dto?.phoneNumber || "").trim(); const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required"); if (!phone) throw new BadRequestException("phoneNumber is required");
@@ -4608,7 +4936,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if ( if (
req.type === BlameRequestType.THIRD_PARTY && req.type === BlameRequestType.THIRD_PARTY &&
!dto.secondPartyPhoneNumber !dto.secondPartyPhoneNumber
@@ -4678,7 +5006,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const now = Date.now(); const now = Date.now();
const verifyOne = async ( const verifyOne = async (
@@ -4805,7 +5133,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim(); const phone = (dto?.phoneNumber || "").trim();
if (!phone) throw new BadRequestException("phoneNumber is required"); if (!phone) throw new BadRequestException("phoneNumber is required");
@@ -4891,7 +5219,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
const phone = (dto?.phoneNumber || "").trim(); const phone = (dto?.phoneNumber || "").trim();
const otp = (dto?.otp || "").trim(); const otp = (dto?.otp || "").trim();
@@ -5662,7 +5990,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON BlameRequest files.", "This endpoint is only for expert-initiated IN_PERSON BlameRequest files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (req.type === BlameRequestType.THIRD_PARTY) { if (req.type === BlameRequestType.THIRD_PARTY) {
return this.expertCompleteThirdPartyFormV2(expert, requestId, formData); return this.expertCompleteThirdPartyFormV2(expert, requestId, formData);
@@ -5696,7 +6024,7 @@ export class RequestManagementService {
if (req.type !== BlameRequestType.CAR_BODY) { if (req.type !== BlameRequestType.CAR_BODY) {
throw new BadRequestException("File type is not CAR_BODY."); throw new BadRequestException("File type is not CAR_BODY.");
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.carBodyForm) { if (!formData.carBodyForm) {
throw new BadRequestException("carBodyForm is required."); throw new BadRequestException("carBodyForm is required.");
@@ -5901,7 +6229,7 @@ export class RequestManagementService {
if (req.type !== BlameRequestType.THIRD_PARTY) { if (req.type !== BlameRequestType.THIRD_PARTY) {
throw new BadRequestException("File type is not THIRD_PARTY."); throw new BadRequestException("File type is not THIRD_PARTY.");
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) { if (!formData.secondParty || !formData.guiltyPartyPhoneNumber) {
throw new BadRequestException( throw new BadRequestException(
@@ -6105,7 +6433,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found"); if (firstIdx === -1) throw new BadRequestException("First party not found");
@@ -6170,7 +6498,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated files.", "This endpoint is only for expert-initiated files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1) if (firstPartyIndex === -1)
@@ -6223,7 +6551,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated files.", "This endpoint is only for expert-initiated files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST); const firstPartyIndex = this.getPartyIndex(req, PartyRole.FIRST);
if (firstPartyIndex === -1) if (firstPartyIndex === -1)
@@ -6288,7 +6616,7 @@ export class RequestManagementService {
"This endpoint is only for expert-initiated IN_PERSON blame files.", "This endpoint is only for expert-initiated IN_PERSON blame files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) { if (req.status !== CaseStatus.WAITING_FOR_SIGNATURES) {
throw new BadRequestException( throw new BadRequestException(
"Request is not waiting for signatures. Current status: " + req.status, "Request is not waiting for signatures. Current status: " + req.status,
@@ -6404,7 +6732,7 @@ export class RequestManagementService {
"This endpoint is only for expert- or registrar-initiated files.", "This endpoint is only for expert- or registrar-initiated files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (!req.expert) req.expert = {} as any; if (!req.expert) req.expert = {} as any;
if (!req.expert.decision) req.expert.decision = {} as any; if (!req.expert.decision) req.expert.decision = {} as any;
@@ -8042,7 +8370,7 @@ export class RequestManagementService {
const req = await this.blameRequestDbService.findById(blameRequestId); const req = await this.blameRequestDbService.findById(blameRequestId);
if (!req) throw new NotFoundException("Blame request not found"); if (!req) throw new NotFoundException("Blame request not found");
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
const claim = await this.claimCaseDbService.findOne({ const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id, blameRequestId: (req as any)._id,
@@ -8367,13 +8695,16 @@ export class RequestManagementService {
partyRole: PartyRole, partyRole: PartyRole,
actor: any, actor: any,
): void { ): void {
// V4/V5 FileMaker files skip the location step — it is auto-completed here.
const skipLocation = !!(req as any).isMadeByFileMaker;
if (partyRole === PartyRole.FIRST) { if (partyRole === PartyRole.FIRST) {
this.pushWorkflowSteps(req, [ const completedSteps = skipLocation
WorkflowStep.CREATED, ? [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM, WorkflowStep.FIRST_LOCATION]
WorkflowStep.FIRST_INITIAL_FORM, : [WorkflowStep.CREATED, WorkflowStep.FIRST_INITIAL_FORM];
]); this.pushWorkflowSteps(req, completedSteps);
req.workflow.currentStep = WorkflowStep.FIRST_LOCATION; req.workflow.currentStep = skipLocation ? WorkflowStep.FIRST_VOICE : WorkflowStep.FIRST_LOCATION;
req.workflow.nextStep = WorkflowStep.FIRST_VOICE; req.workflow.nextStep = skipLocation ? WorkflowStep.FIRST_DESCRIPTION : WorkflowStep.FIRST_VOICE;
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
const guiltyUserId = req.parties?.[firstIdx]?.person?.userId; const guiltyUserId = req.parties?.[firstIdx]?.person?.userId;
@@ -8388,9 +8719,12 @@ export class RequestManagementService {
(req as any).markModified("expert"); (req as any).markModified("expert");
} }
} else { } else {
this.pushWorkflowSteps(req, [WorkflowStep.SECOND_INITIAL_FORM]); const completedSteps = skipLocation
req.workflow.currentStep = WorkflowStep.SECOND_LOCATION; ? [WorkflowStep.SECOND_INITIAL_FORM, WorkflowStep.SECOND_LOCATION]
req.workflow.nextStep = WorkflowStep.SECOND_VOICE; : [WorkflowStep.SECOND_INITIAL_FORM];
this.pushWorkflowSteps(req, completedSteps);
req.workflow.currentStep = skipLocation ? WorkflowStep.SECOND_VOICE : WorkflowStep.SECOND_LOCATION;
req.workflow.nextStep = skipLocation ? WorkflowStep.SECOND_DESCRIPTION : WorkflowStep.SECOND_VOICE;
} }
} }
@@ -8623,7 +8957,7 @@ export class RequestManagementService {
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Blame request not found"); if (!req) throw new NotFoundException("Blame request not found");
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
const partyRole = this.resolveV3InquiryPartyRole(req); const partyRole = this.resolveV3InquiryPartyRole(req);
if (!partyRole) { if (!partyRole) {
@@ -8796,7 +9130,7 @@ export class RequestManagementService {
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
if (partyRole === PartyRole.FIRST) { if (partyRole === PartyRole.FIRST) {
if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) { if (!this.hasPartyInquiriesComplete(req, PartyRole.FIRST)) {
@@ -8934,7 +9268,7 @@ export class RequestManagementService {
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
this.assertBlameV3AccidentFieldsPhase(req); this.assertBlameV3AccidentFieldsPhase(req);
if (!req.expert) req.expert = {} as any; if (!req.expert) req.expert = {} as any;
@@ -8991,20 +9325,30 @@ export class RequestManagementService {
async expertUploadBlameVideoV3( async expertUploadBlameVideoV3(
expert: any, expert: any,
requestId: string, requestId: string,
file: Express.Multer.File, file: Express.Multer.File | undefined,
): Promise<{ ): Promise<{
requestId: string; requestId: string;
publicId: string; publicId: string;
videoId: string; videoId: string | undefined;
status: string; status: string;
message: string; message: string;
}> { }> {
if (!file) throw new BadRequestException("Video file is required");
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
this.verifyExpertAccessForBlameV2(req, expert); await this.verifyExpertAccessForBlameV2(req, expert);
// Idempotent: if blame is already completed (car-capture auto-completed it
// for V4/V5, or a previous call already ran), return success immediately.
if ((req as any).status === CaseStatus.COMPLETED) {
return {
requestId: String(req._id),
publicId: req.publicId,
videoId: undefined,
status: (req as any).status,
message: "File already completed.",
};
}
if (!(req.expert?.decision as any)?.fields?.accidentWay) { if (!(req.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException( throw new BadRequestException(
@@ -9047,18 +9391,22 @@ export class RequestManagementService {
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found"); if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx]; const firstParty = req.parties[firstIdx];
// Store video if provided; skip silently for V4/V5 where it is optional.
let videoId: string | undefined;
if (file) {
if (firstParty?.evidence?.videoId) { if (firstParty?.evidence?.videoId) {
throw new ConflictException("Blame accident video already uploaded"); throw new ConflictException("Blame accident video already uploaded");
} }
const videoDocument = await this.blameVideoDbService.create({ const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename, fileName: file.filename,
path: file.path, path: file.path,
requestId: new Types.ObjectId(requestId), requestId: new Types.ObjectId(requestId),
} as any); } as any);
if (!firstParty.evidence) firstParty.evidence = {} as any; if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id); firstParty.evidence.videoId = String((videoDocument as any)._id);
videoId = firstParty.evidence.videoId;
}
this.pushWorkflowSteps(req, [ this.pushWorkflowSteps(req, [
WorkflowStep.FIRST_VIDEO, WorkflowStep.FIRST_VIDEO,
@@ -9066,7 +9414,7 @@ export class RequestManagementService {
]); ]);
req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION; req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES; req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.status = CaseStatus.WAITING_FOR_EXPERT; req.status = CaseStatus.COMPLETED;
if (!Array.isArray(req.history)) req.history = []; if (!Array.isArray(req.history)) req.history = [];
req.history.push({ req.history.push({
@@ -9076,7 +9424,7 @@ export class RequestManagementService {
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(), actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "field_expert", actorType: "field_expert",
}, },
metadata: { videoId: firstParty.evidence.videoId }, metadata: { videoId: videoId ?? null },
} as any); } as any);
if (typeof (req as any).markModified === "function") { if (typeof (req as any).markModified === "function") {
@@ -9115,10 +9463,11 @@ export class RequestManagementService {
return { return {
requestId: String(req._id), requestId: String(req._id),
publicId: req.publicId, publicId: req.publicId,
videoId: firstParty.evidence.videoId, videoId,
status: req.status, status: req.status,
message: message: file
"Blame accident video uploaded. File is now waiting for expert review.", ? "Blame accident video uploaded. File is now waiting for expert review."
: "File completed. Claim is now ready for damage expert review.",
}; };
} }
@@ -9188,7 +9537,7 @@ export class RequestManagementService {
if (!voice) throw new BadRequestException("File is required"); if (!voice) throw new BadRequestException("File is required");
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
const role = this.resolvePartyRoleV3(req, partyRole); const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role); this.assertBlameV3PartyDetailPhase(req, role);
const idx = this.getPartyIndex(req, role); const idx = this.getPartyIndex(req, role);
@@ -9229,7 +9578,18 @@ export class RequestManagementService {
) { ) {
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
// V4/V5: location step is skipped — store whatever is sent but do not
// validate against workflow phase (the step was auto-completed at inquiry time).
if ((req as any).isMadeByFileMaker) {
const role = this.resolvePartyRoleV3(req, partyRole);
const idx = this.getPartyIndex(req, role);
if (idx !== -1 && body) req.parties[idx].location = body as any;
await (req as any).save();
return { requestId: req._id, publicId: req.publicId, partyRole: role };
}
const role = this.resolvePartyRoleV3(req, partyRole); const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role); this.assertBlameV3PartyDetailPhase(req, role);
@@ -9261,7 +9621,7 @@ export class RequestManagementService {
) { ) {
const req = await this.blameRequestDbService.findById(requestId); const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found"); if (!req) throw new NotFoundException("Request not found");
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
const role = this.resolvePartyRoleV3(req, partyRole); const role = this.resolvePartyRoleV3(req, partyRole);
this.assertBlameV3PartyDetailPhase(req, role); this.assertBlameV3PartyDetailPhase(req, role);
@@ -9299,7 +9659,7 @@ export class RequestManagementService {
"CAR_BODY accident type is only for CAR_BODY files.", "CAR_BODY accident type is only for CAR_BODY files.",
); );
} }
this.verifyExpertAccessForBlameV2(req, actor); await this.verifyExpertAccessForBlameV2(req, actor);
this.assertBlameV3ExpertInPerson(req); this.assertBlameV3ExpertInPerson(req);
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST); const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
@@ -9338,4 +9698,315 @@ export class RequestManagementService {
await (req as any).save(); await (req as any).save();
return { requestId: req._id, publicId: req.publicId }; return { requestId: req._id, publicId: req.publicId };
} }
/**
* V5 variant of expertUploadBlameVideoV3.
* Same flow as V3/V4 but additionally marks the linked claim with
* `requiresFileMakerApproval: true` so that after the owner signs,
* the claim is held at WAITING_FOR_FILE_MAKER_APPROVAL rather than
* being auto-submitted to fanavaran.
*/
async expertUploadBlameVideoV5(
expert: any,
requestId: string,
file: Express.Multer.File | undefined,
): Promise<{
requestId: string;
publicId: string;
videoId: string | undefined;
status: string;
message: string;
}> {
const req = await this.blameRequestDbService.findById(requestId);
if (!req) throw new NotFoundException("Request not found");
this.assertBlameV3ExpertInPerson(req);
await this.verifyExpertAccessForBlameV2(req, expert);
// Idempotent: blame already completed (car-capture auto-completed it for
// V4/V5, or a previous call already ran).
if ((req as any).status === CaseStatus.COMPLETED) {
return {
requestId: String(req._id),
publicId: req.publicId,
videoId: undefined,
status: (req as any).status,
message: "File already completed.",
};
}
if (!(req.expert?.decision as any)?.fields?.accidentWay) {
throw new BadRequestException(
"Submit accident fields before uploading the blame accident video.",
);
}
const requiredSigs = req.type === BlameRequestType.CAR_BODY ? 1 : 2;
const signed = (req.parties ?? []).filter(
(p: any) => p?.confirmation != null,
);
if (signed.length < requiredSigs) {
throw new BadRequestException(
"All required party signatures must be collected before the blame accident video.",
);
}
const claim = await this.claimCaseDbService.findOne({
blameRequestId: (req as any)._id,
});
if (!claim) {
throw new BadRequestException(
"Claim not found. Complete guilty-party inquiries first.",
);
}
if (!claim.media?.videoCaptureId) {
throw new BadRequestException(
"Upload the claim walk-around video (car-capture) before the blame accident video.",
);
}
if (
claim.workflow?.currentStep !==
ClaimWorkflowStep.UPLOAD_REQUIRED_DOCUMENTS
) {
throw new BadRequestException(
"Complete capture-part (parts, angles, and capture-phase documents) before the blame accident video.",
);
}
const firstIdx = this.getPartyIndex(req, PartyRole.FIRST);
if (firstIdx === -1) throw new BadRequestException("First party not found");
const firstParty = req.parties[firstIdx];
// Store video if provided; skip silently for V4/V5 where it is optional.
let videoId: string | undefined;
if (file) {
if (firstParty?.evidence?.videoId) {
throw new ConflictException("Blame accident video already uploaded");
}
const videoDocument = await this.blameVideoDbService.create({
fileName: file.filename,
path: file.path,
requestId: new Types.ObjectId(requestId),
} as any);
if (!firstParty.evidence) firstParty.evidence = {} as any;
firstParty.evidence.videoId = String((videoDocument as any)._id);
videoId = firstParty.evidence.videoId;
}
this.pushWorkflowSteps(req, [
WorkflowStep.FIRST_VIDEO,
WorkflowStep.WAITING_FOR_GUILT_DECISION,
]);
req.workflow.currentStep = WorkflowStep.WAITING_FOR_GUILT_DECISION;
req.workflow.nextStep = WorkflowStep.WAITING_FOR_SIGNATURES;
req.status = CaseStatus.COMPLETED;
if (!Array.isArray(req.history)) req.history = [];
req.history.push({
type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "file_reviewer",
},
metadata: { videoId: videoId ?? null },
} as any);
if (typeof (req as any).markModified === "function") {
(req as any).markModified("parties");
}
await (req as any).save();
// Advance the claim into the damage-expert queue AND mark FileMaker approval
// required before fanavaran submission.
const fileMakerActorId = req.initiatedByFieldExpertId ?? null;
await this.claimCaseDbService.findByIdAndUpdate(String(claim._id), {
$set: {
status: ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
claimStatus: ClaimStatus.PENDING,
"workflow.currentStep": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
requiresFileMakerApproval: true,
...(fileMakerActorId
? { fileMakerApprovalActorId: new Types.ObjectId(String(fileMakerActorId)) }
: {}),
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
history: {
type: "V5_BLAME_ACCIDENT_VIDEO_UPLOADED",
actor: {
actorId: new Types.ObjectId(expert.sub),
actorName: `${expert.firstName || ""} ${expert.lastName || ""}`.trim(),
actorType: "file_reviewer",
},
timestamp: new Date(),
metadata: {
stepKey: ClaimWorkflowStep.USER_SUBMISSION_COMPLETE,
description:
"V5 field workflow complete. Claim ready for damage expert review (FileMaker approval required before fanavaran).",
v5InPersonFlow: true,
fileMakerActorId: fileMakerActorId ? String(fileMakerActorId) : null,
},
},
},
});
return {
requestId: String(req._id),
publicId: req.publicId,
videoId,
status: req.status,
message: file
? "Blame accident video uploaded. File is now in expert review queue. After the full claim flow completes and the owner signs, the FileMaker must approve before fanavaran submission."
: "File completed. Claim is now ready for damage expert review.",
};
}
/**
* V5: FileMaker approves the completed claim.
*
* Preconditions:
* - claim.requiresFileMakerApproval === true
* - claim.status === WAITING_FOR_FILE_MAKER_APPROVAL
* - actor is FILE_MAKER and is the original creator of the linked blame file
*
* On approval: triggers fanavaran submission and moves claim to COMPLETED.
*/
async fileMakerApproveV5(
fileMaker: any,
claimRequestId: string,
): Promise<{
claimRequestId: string;
publicId: string;
status: string;
message: string;
}> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) throw new NotFoundException("Claim not found");
this.assertFileMakerApprovalAccess(claim, fileMaker);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) {
throw new BadRequestException(
`Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to approve. Current: ${claim.status}`,
);
}
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
const historyEntry = {
type: "V5_FILE_MAKER_APPROVED",
actor: {
actorId: new Types.ObjectId(fileMaker.sub),
actorName,
actorType: "file_maker",
},
timestamp: new Date(),
metadata: {},
};
// Move claim to COMPLETED and clear the approval gate
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.COMPLETED,
claimStatus: ClaimStatus.APPROVED,
requiresFileMakerApproval: false,
"workflow.currentStep": ClaimWorkflowStep.CLAIM_COMPLETED,
"workflow.nextStep": ClaimWorkflowStep.CLAIM_COMPLETED,
},
$push: {
"workflow.completedSteps": ClaimWorkflowStep.INSURER_REVIEW,
history: historyEntry,
},
});
return {
claimRequestId,
publicId: claim.publicId,
status: ClaimCaseStatus.COMPLETED,
message:
"Claim approved by FileMaker. Claim is now marked COMPLETED. " +
"Proceed with fanavaran submission via the claim service.",
};
}
/**
* V5: FileMaker rejects the completed claim back to expert review.
*
* Moves claim from WAITING_FOR_FILE_MAKER_APPROVAL → WAITING_FOR_DAMAGE_EXPERT
* so the FileReviewer can re-lock it and adjust the damage assessment or
* restart the back-and-forth with the user.
*/
async fileMakerRejectV5(
fileMaker: any,
claimRequestId: string,
reason?: string,
): Promise<{
claimRequestId: string;
publicId: string;
status: string;
message: string;
}> {
const claim = await this.claimCaseDbService.findById(claimRequestId);
if (!claim) throw new NotFoundException("Claim not found");
this.assertFileMakerApprovalAccess(claim, fileMaker);
if (claim.status !== ClaimCaseStatus.WAITING_FOR_FILE_MAKER_APPROVAL) {
throw new BadRequestException(
`Claim must be in WAITING_FOR_FILE_MAKER_APPROVAL status to reject. Current: ${claim.status}`,
);
}
const actorName = `${fileMaker.firstName || ""} ${fileMaker.lastName || ""}`.trim();
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
$set: {
status: ClaimCaseStatus.FILE_MAKER_REJECTED,
claimStatus: ClaimStatus.NEEDS_REVISION,
"workflow.currentStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
},
$push: {
history: {
type: "V5_FILE_MAKER_REJECTED",
actor: {
actorId: new Types.ObjectId(fileMaker.sub),
actorName,
actorType: "file_maker",
},
timestamp: new Date(),
metadata: { reason: reason ?? null },
},
},
});
return {
claimRequestId,
publicId: claim.publicId,
status: ClaimCaseStatus.FILE_MAKER_REJECTED,
message:
"Claim rejected by FileMaker. FileReviewer can re-lock the claim and adjust the damage assessment or restart the user interaction.",
};
}
/** Guard for V5 FileMaker approval endpoints. */
private assertFileMakerApprovalAccess(claim: any, fileMaker: any): void {
if (fileMaker?.role !== RoleEnum.FILE_MAKER) {
throw new ForbiddenException("Only FileMakers can perform this action.");
}
if (!claim.requiresFileMakerApproval) {
throw new ForbiddenException(
"This claim does not require FileMaker approval (not a V5 file).",
);
}
// Scope to the FileMaker who created the linked blame file
if (
claim.fileMakerApprovalActorId &&
String(claim.fileMakerApprovalActorId) !== String(fileMaker.sub)
) {
throw new ForbiddenException(
"Only the FileMaker who created this file can approve or reject it.",
);
}
}
} }

View File

@@ -40,6 +40,7 @@ import {
BlameConfessionDtoV2, BlameConfessionDtoV2,
CreateBlameRequestDtoV2, CreateBlameRequestDtoV2,
DescriptionDto, DescriptionDto,
InitialFormVinDto,
LocationDto, LocationDto,
CarBodyFormDto, CarBodyFormDto,
} from "./dto/create-request-management.dto"; } from "./dto/create-request-management.dto";
@@ -197,6 +198,26 @@ export class RequestManagementV2Controller {
return this.requestManagementService.initialFormV2(requestId, body, user); return this.requestManagementService.initialFormV2(requestId, body, user);
} }
@Post("/initial-form-vin/:requestId")
@ApiParam({ name: "requestId", description: "Blame request ID" })
@ApiBody({
type: InitialFormVinDto,
description:
"Same identity/license fields as the plate form, but with `vin` (17-char chassis number) instead of `plate`.",
})
@UseGuards(GlobalGuard)
async initialFormVinV2(
@Param("requestId") requestId: string,
@Body() body: InitialFormVinDto,
@CurrentUser() user,
) {
return this.requestManagementService.initialFormVinV2(
requestId,
body,
user,
);
}
@Post("/add-detail-location/:requestId") @Post("/add-detail-location/:requestId")
@ApiParam({ name: "requestId" }) @ApiParam({ name: "requestId" })
@ApiBody({ type: LocationDto }) @ApiBody({ type: LocationDto })

View File

@@ -893,6 +893,48 @@ export class SandHubService {
}; };
} }
/**
* ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`).
*
* When `vinChassis` inquiry is disabled (mock mode) the response shape mirrors
* `buildMockPlateInquiryRaw` so the downstream mapper (`mapEsgPolicyByPlateToOldFormat`)
* can normalise it into the same `{ raw, mapped }` contract used by the plate path.
*
* @param chassisNo - 17-character VIN / chassis number
* @param options - optional per-tenant client scope
*/
async getPolicyByChassisInquiry(
chassisNo: string,
options?: SandHubInquiryOptions,
): Promise<{ raw: any; mapped: any }> {
const baseUrl = process.env.ESG_URL ?? "http://192.168.20.22:8085";
const requestUrl = `${baseUrl}/inquiry/policyByChassis`;
const requestPayload = { chassisNo };
const live = await this.isInquiryLive("vinChassis", options);
if (!live) {
const ctx = await this.mockCompanyContext(options);
const raw = this.buildMockPlateInquiryRaw(ctx);
this.logger.debug(
`[MOCK] getPolicyByChassisInquiry chassisNo=${chassisNo}`,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
return { raw, mapped };
}
const raw = await this.makeEsgRequest(
requestUrl,
requestPayload,
"vinChassis",
options,
);
const mapped = this.mapEsgPolicyByPlateToOldFormat(raw);
return { raw, mapped };
}
private async makeSandHubRequest( private async makeSandHubRequest(
url: string, url: string,
payload: any, payload: any,

View File

@@ -218,5 +218,9 @@
"دمنده بخاری": false, "دمنده بخاری": false,
"کمپرسور کولر": false, "کمپرسور کولر": false,
"کندانسور": false, "کندانسور": false,
"اواپراتور": false "اواپراتور": false,
"لاستیک": false,
"مه شکن": false,
"رینگ": false,
"فن": false
} }

View File

@@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class CreateSuperAdminDto {
@ApiProperty({
example: "ops@yara724.ir",
description: "Email address for the new super-admin account.",
})
@IsEmail()
email: string;
@ApiProperty({ example: "Str0ngP@ss!" })
@IsString()
@IsNotEmpty()
password: string;
@ApiPropertyOptional({ example: "Operations" })
@IsOptional()
@IsString()
firstName?: string;
@ApiPropertyOptional({ example: "Team" })
@IsOptional()
@IsString()
lastName?: string;
}
export class CreateFieldExpertAdminDto {
@ApiProperty({ example: "expert@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "Expert@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ example: "Ali" })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: "Mohammadi" })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: "09121234567" })
@IsString()
@IsNotEmpty()
mobile: string;
@ApiPropertyOptional({ example: "02112345678" })
@IsOptional()
@IsString()
phone?: string;
}
export class CreateRegistrarAdminDto {
@ApiProperty({ example: "registrar@insurer.ir" })
@IsEmail()
email: string;
@ApiProperty({ example: "Registrar@724" })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({
example: "664a1b2c3d4e5f6789012345",
description: "Mongo ObjectId of the insurer client this registrar belongs to.",
})
@IsString()
@IsNotEmpty()
clientId: string;
}

View File

@@ -0,0 +1,74 @@
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose";
import { HashService } from "src/utils/hash/hash.service";
import {
SuperAdminModel,
SuperAdminDocument,
} from "../schema/super-admin.schema";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Injectable()
export class SuperAdminDbService implements OnModuleInit {
private readonly logger = new Logger(SuperAdminDbService.name);
constructor(
@InjectModel(SuperAdminModel.name)
private readonly superAdminModel: Model<SuperAdminDocument>,
private readonly hashService: HashService,
) {}
/**
* Seeds the default super-admin account on first boot.
* Reads credentials from environment variables:
* SUPER_ADMIN_EMAIL (default: super-admin@yara724.ir)
* SUPER_ADMIN_PASSWORD (default: SuperAdmin@724)
*/
async onModuleInit() {
const email = (
process.env.SUPER_ADMIN_EMAIL ?? "super-admin@yara724.ir"
).toLowerCase().trim();
const existing = await this.superAdminModel.findOne({ email }).lean();
if (existing) {
this.logger.log(`Super-admin already exists (email=${email}), skipping seed.`);
return;
}
const rawPassword = process.env.SUPER_ADMIN_PASSWORD ?? "SuperAdmin@724";
const password = await this.hashService.hash(rawPassword);
await this.superAdminModel.create({
email,
password,
role: RoleEnum.SUPER_ADMIN,
firstName: "Super",
lastName: "Admin",
});
this.logger.log(`Default super-admin seeded (email=${email}).`);
}
async findOne(
filter: FilterQuery<SuperAdminDocument>,
): Promise<SuperAdminDocument | null> {
return this.superAdminModel.findOne(filter).lean() as any;
}
async findByLoginIdentifier(identifier: string): Promise<SuperAdminDocument | null> {
const id = identifier.trim();
return this.superAdminModel.findOne({ email: id }).lean() as any;
}
async create(data: Partial<SuperAdminModel>): Promise<SuperAdminDocument> {
return this.superAdminModel.create(data);
}
async updateOne(filter: FilterQuery<SuperAdminDocument>, update: any) {
return this.superAdminModel.updateOne(filter, update);
}
async findAll(): Promise<SuperAdminDocument[]> {
return this.superAdminModel.find().lean() as any;
}
}

View File

@@ -0,0 +1,30 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { HydratedDocument } from "mongoose";
import { RoleEnum } from "src/Types&Enums/role.enum";
@Schema({
collection: "super-admins",
versionKey: false,
timestamps: true,
})
export class SuperAdminModel {
@Prop({ required: true, unique: true, index: true })
email: string;
@Prop({ required: true })
password: string;
@Prop({ required: true, default: RoleEnum.SUPER_ADMIN })
role: RoleEnum;
@Prop()
firstName?: string;
@Prop()
lastName?: string;
createdAt: Date;
}
export type SuperAdminDocument = HydratedDocument<SuperAdminModel>;
export const SuperAdminSchema = SchemaFactory.createForClass(SuperAdminModel);

View File

@@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { RoleEnum } from "src/Types&Enums/role.enum";
/**
* Verifies a Bearer JWT and restricts access to `super_admin` role only.
* Use this guard on all endpoints that should be reachable only by the
* super-admin panel.
*/
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException("Token not found");
}
let payload: any;
try {
payload = await this.jwtService.verifyAsync(token, {
secret: `${process.env.JWT_SECRET}`,
});
} catch {
throw new UnauthorizedException("Invalid or expired token");
}
if (payload?.role !== RoleEnum.SUPER_ADMIN) {
throw new UnauthorizedException("Super-admin access required");
}
(request as any).user = payload;
(request as any).identity = payload;
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : undefined;
}
}

View File

@@ -0,0 +1,162 @@
import {
Body,
Controller,
Get,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { SuperAdminGuard } from "./guards/super-admin.guard";
import { SuperAdminService } from "./super-admin.service";
import {
CreateSuperAdminDto,
CreateFieldExpertAdminDto,
CreateRegistrarAdminDto,
} from "./dto/super-admin.dto";
import { ClientDto } from "src/client/dto/create-client.dto";
import {
InsurerRegisterDto,
GenuineRegisterDto,
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
import { SystemSettingsService } from "src/system-settings/system-settings.service";
import {
SystemSettingsResponseDto,
UpdateSystemSettingsDto,
} from "src/system-settings/dto/system-settings.dto";
import { SetExternalInquiriesLiveDto } from "src/client/dto/external-inquiries-live.dto";
@ApiTags("super-admin")
@ApiBearerAuth()
@UseGuards(SuperAdminGuard)
@Controller("super-admin")
export class SuperAdminController {
constructor(
private readonly superAdminService: SuperAdminService,
private readonly systemSettingsService: SystemSettingsService,
) {}
// ── Super-admin account management ───────────────────────────────────────
@Get("accounts")
@ApiOperation({ summary: "List all super-admin accounts" })
listSuperAdmins() {
return this.superAdminService.listSuperAdmins();
}
@Post("accounts")
@ApiOperation({ summary: "Create an additional super-admin account" })
@ApiBody({ type: CreateSuperAdminDto })
createSuperAdmin(@Body() body: CreateSuperAdminDto) {
return this.superAdminService.createSuperAdmin(body);
}
// ── Client / insurer management ──────────────────────────────────────────
@Post("clients")
@ApiOperation({
summary: "Create a new insurer client",
description: "Registers a new insurer (client) in the platform.",
})
@ApiBody({ type: ClientDto })
createClient(@Body() body: ClientDto) {
return this.superAdminService.createClient(body);
}
@Get("clients")
@ApiOperation({ summary: "List all insurer clients" })
listClients() {
return this.superAdminService.listClients();
}
@Get("clients/names")
@ApiOperation({ summary: "List insurer client names and IDs" })
listClientNames() {
return this.superAdminService.listClientNames();
}
// ── Actor registration ───────────────────────────────────────────────────
@Post("register/insurer")
@ApiOperation({ summary: "Register a new insurer (company) actor" })
@ApiBody({ type: InsurerRegisterDto })
registerInsurer(@Body() body: InsurerRegisterDto) {
return this.superAdminService.registerInsurer(body);
}
@Post("register/genuine")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Register a genuine actor",
description:
"Kept for legacy clients. Use the unified onboarding flow instead.",
})
@ApiBody({ type: GenuineRegisterDto })
registerGenuine(@Body() body: GenuineRegisterDto) {
return this.superAdminService.registerGenuine(body);
}
@Post("register/legal")
@ApiOperation({
deprecated: true,
summary: "[DEPRECATED] Register a legal actor",
description:
"Kept for legacy clients. Use the unified onboarding flow instead.",
})
@ApiBody({ type: LegalRegisterDto })
registerLegal(@Body() body: LegalRegisterDto) {
return this.superAdminService.registerLegal(body);
}
@Post("create-field-expert")
@ApiOperation({ summary: "Create a field expert" })
@ApiBody({ type: CreateFieldExpertAdminDto })
createFieldExpert(@Body() body: CreateFieldExpertAdminDto) {
return this.superAdminService.createFieldExpert(body);
}
@Post("create-registrar")
@ApiOperation({ summary: "Create a registrar" })
@ApiBody({ type: CreateRegistrarAdminDto })
createRegistrar(@Body() body: CreateRegistrarAdminDto) {
return this.superAdminService.createRegistrar(body);
}
// ── System settings ──────────────────────────────────────────────────────
@Get("system-settings")
@ApiOperation({ summary: "Get global system settings" })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
getSystemSettings() {
return this.systemSettingsService.getSettingsView();
}
@Patch("system-settings")
@ApiOperation({ summary: "Update global system settings" })
@ApiBody({ type: UpdateSystemSettingsDto })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
updateSystemSettings(@Body() body: UpdateSystemSettingsDto) {
return this.systemSettingsService.updateSettings(body);
}
@Patch("external-inquiries-live")
@ApiOperation({
summary: "Enable or disable live external inquiries globally",
description:
"Updates `system_settings.externalApis.sandHubUseLiveApi`. When disabled, all inquiry flows use mocks.",
})
@ApiBody({ type: SetExternalInquiriesLiveDto })
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
setExternalInquiriesLive(@Body() body: SetExternalInquiriesLiveDto) {
return this.systemSettingsService.updateSettings({
externalApis: { sandHubUseLiveApi: body?.enabled === true },
});
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from "@nestjs/common";
import { ClientModule } from "src/client/client.module";
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
import { HashModule } from "src/utils/hash/hash.module";
import { UsersModule } from "src/users/users.module";
import { SuperAdminController } from "./super-admin.controller";
import { SuperAdminService } from "./super-admin.service";
import { SuperAdminGuard } from "./guards/super-admin.guard";
/**
* Super-admin feature module.
*
* The `SuperAdminDbService` and its Mongoose model are registered in `AuthModule`
* (which is `@Global()`) to keep login routing central. This module only needs
* to import the other feature modules it proxies.
*
* Dependency chain:
* SuperAdminGuard ← JwtService (global via AuthModule JwtModule)
* SuperAdminService ← ActorAuthService (global), ClientService (ClientModule),
* SuperAdminDbService (global via AuthModule),
* FieldExpertDbService + RegistrarDbService (UsersModule)
*/
@Module({
imports: [
ClientModule,
SystemSettingsModule,
HashModule,
UsersModule,
],
controllers: [SuperAdminController],
providers: [SuperAdminService, SuperAdminGuard],
exports: [SuperAdminService, SuperAdminGuard],
})
export class SuperAdminModule {}

View File

@@ -0,0 +1,102 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Types } from "mongoose";
import { HashService } from "src/utils/hash/hash.service";
import { ClientService } from "src/client/client.service";
import { ClientDto } from "src/client/dto/create-client.dto";
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
import {
InsurerRegisterDto,
GenuineRegisterDto,
LegalRegisterDto,
} from "src/auth/dto/actor/register.actor.dto";
import { SuperAdminDbService } from "./entities/db-service/super-admin.db.service";
import {
CreateSuperAdminDto,
CreateFieldExpertAdminDto,
CreateRegistrarAdminDto,
} from "./dto/super-admin.dto";
import { RoleEnum } from "src/Types&Enums/role.enum";
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
@Injectable()
export class SuperAdminService {
constructor(
private readonly superAdminDbService: SuperAdminDbService,
private readonly hashService: HashService,
private readonly clientService: ClientService,
private readonly actorAuthService: ActorAuthService,
private readonly fieldExpertDbService: FieldExpertDbService,
private readonly registrarDbService: RegistrarDbService,
) {}
/** List all super-admin accounts (sans password). */
async listSuperAdmins() {
const admins = await this.superAdminDbService.findAll();
return admins.map(({ password: _pw, ...rest }) => rest);
}
/** Create an additional super-admin account. */
async createSuperAdmin(body: CreateSuperAdminDto) {
const email = body.email.toLowerCase().trim();
const existing = await this.superAdminDbService.findOne({ email });
if (existing) {
throw new ConflictException(
"A super-admin with this email already exists.",
);
}
const password = await this.hashService.hash(body.password);
const created = await this.superAdminDbService.create({
email,
password,
role: RoleEnum.SUPER_ADMIN,
firstName: body.firstName,
lastName: body.lastName,
});
const { password: _pw, ...rest } = (created as any).toObject
? (created as any).toObject()
: (created as any);
return rest;
}
// ── Client management ────────────────────────────────────────────────────
async createClient(body: ClientDto) {
return this.clientService.addClient(body);
}
async listClients() {
return this.clientService.getClients();
}
async listClientNames() {
return this.clientService.getClientList();
}
// ── Actor registration proxies ───────────────────────────────────────────
async registerInsurer(body: InsurerRegisterDto) {
return this.actorAuthService.insurerRegister(body);
}
async registerGenuine(body: GenuineRegisterDto) {
return this.actorAuthService.genuineRegister(body);
}
async registerLegal(body: LegalRegisterDto) {
return this.actorAuthService.legalRegister(body);
}
async createFieldExpert(body: CreateFieldExpertAdminDto) {
return this.actorAuthService.createFieldExpertMock(body);
}
async createRegistrar(body: CreateRegistrarAdminDto) {
return this.actorAuthService.createRegistrarMock(body);
}
}

View File

@@ -23,7 +23,7 @@ export class SystemSettingsController {
@Get() @Get()
@UseGuards(SettingsJwtGuard, RolesGuard) @UseGuards(SettingsJwtGuard, RolesGuard)
@Roles(RoleEnum.ADMIN, RoleEnum.COMPANY) @Roles(RoleEnum.ADMIN, RoleEnum.COMPANY, RoleEnum.SUPER_ADMIN)
@ApiOperation({ @ApiOperation({
summary: "Get global system settings (external API toggles)", summary: "Get global system settings (external API toggles)",
description: description:
@@ -36,7 +36,7 @@ export class SystemSettingsController {
@Patch() @Patch()
@UseGuards(SettingsJwtGuard, RolesGuard) @UseGuards(SettingsJwtGuard, RolesGuard)
@Roles(RoleEnum.ADMIN) @Roles(RoleEnum.ADMIN, RoleEnum.SUPER_ADMIN)
@ApiOperation({ @ApiOperation({
summary: "Update global system settings", summary: "Update global system settings",
description: description: