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,11 +2641,55 @@ 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) {
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor); 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);
}
// 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);
assertClaimCaseForExpertActor(claim, actor, linkedBlame); // 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);
}
// 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,15 +4977,18 @@ 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.
if (enrichedEvaluation.damageExpertReply !== undefined) { // Previously these were gated on isFactorValidationPending, which hid the
evaluationForApi.damageExpertReply = // expert assessment once the claim moved to user-side statuses such as
enrichedEvaluation.damageExpertReply; // INSURER_REVIEW_AWAITING_OWNER_SIGN. Historical assessment data should
} // always be visible once submitted.
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) { if (enrichedEvaluation.damageExpertReply !== undefined) {
evaluationForApi.damageExpertReplyFinal = evaluationForApi.damageExpertReply =
enrichedEvaluation.damageExpertReplyFinal; enrichedEvaluation.damageExpertReply;
} }
if (enrichedEvaluation.damageExpertReplyFinal !== undefined) {
evaluationForApi.damageExpertReplyFinal =
enrichedEvaluation.damageExpertReplyFinal;
} }
if (enrichedEvaluation.ownerInsurerApproval !== undefined) { if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
evaluationForApi.ownerInsurerApproval = evaluationForApi.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)
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) { let active: boolean;
throw new BadRequestException("isActive must be true/false"); if (typeof isActive === "boolean") {
active = isActive;
} else {
const normalized = String(isActive ?? "").trim().toLowerCase();
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
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,
) { ) {
await this.mediaPolicyService.assertForBlame(file, requestId, "video"); if (file) {
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: [

File diff suppressed because it is too large Load Diff

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: