From 0dcb2cf2ca087a0d40ec22ebfad796bf3d0bb006 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 15:34:01 +0330 Subject: [PATCH 1/2] YARA-947, YARA-1038 --- .../dto/client-external-inquiries.dto.ts | 8 + src/common/types/external-inquiry.types.ts | 2 + .../dto/create-request-management.dto.ts | 47 +++ .../request-management.service.ts | 308 ++++++++++++++++++ .../request-management.v2.controller.ts | 21 ++ src/sand-hub/sand-hub.service.ts | 42 +++ 6 files changed, 428 insertions(+) diff --git a/src/client/dto/client-external-inquiries.dto.ts b/src/client/dto/client-external-inquiries.dto.ts index 5b8d781..01abad2 100644 --- a/src/client/dto/client-external-inquiries.dto.ts +++ b/src/client/dto/client-external-inquiries.dto.ts @@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags { }) @IsBoolean() 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( diff --git a/src/common/types/external-inquiry.types.ts b/src/common/types/external-inquiry.types.ts index 5e5e415..4241f7d 100644 --- a/src/common/types/external-inquiry.types.ts +++ b/src/common/types/external-inquiry.types.ts @@ -6,6 +6,7 @@ export const EXTERNAL_INQUIRY_TYPES = [ "sheba", "drivingLicense", "carOwnership", + "vinChassis", ] as const; export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number]; @@ -21,6 +22,7 @@ export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record< sheba: false, drivingLicense: false, carOwnership: false, + vinChassis: false, }; export type ExternalInquiryFlags = Record; diff --git a/src/request-management/dto/create-request-management.dto.ts b/src/request-management/dto/create-request-management.dto.ts index 9e28ec3..06f72a3 100644 --- a/src/request-management/dto/create-request-management.dto.ts +++ b/src/request-management/dto/create-request-management.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, MaxLength } from "class-validator"; import { Types } from "mongoose"; import { AddPlateDto } from "src/profile/dto/user/AddPlateDto"; import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum"; @@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 { 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 { // @ApiProperty() // firstPartyFile?: FirstPartyFileDto; diff --git a/src/request-management/request-management.service.ts b/src/request-management/request-management.service.ts index 0bfb5d2..7f68917 100644 --- a/src/request-management/request-management.service.ts +++ b/src/request-management/request-management.service.ts @@ -18,6 +18,7 @@ import { CarBodyFormDto, CarBodySecondForm, InitialFormDto, + InitialFormVinDto, } from "src/request-management/dto/create-request-management.dto"; import { BlameVideoDbService } from "src/request-management/entities/db-service/blame-video.db.service"; import { BlameVoiceDbService } from "src/request-management/entities/db-service/blame.voice.db.service"; @@ -1475,6 +1476,313 @@ export class RequestManagementService { } } + /** + * V2 initial-form submitted with a VIN/chassis number instead of a plate. + * + * Performs: + * 1. ESG `/inquiry/policyByChassis` (or its mock when `vinChassis` is off) + * 2. Personal identity inquiry (same as the plate path) + * + * The inquiry result is mapped through the same `mapEsgPolicyByPlateToOldFormat` + * normaliser used by `getTejaratBlockInquiry`, so all downstream party/vehicle + * /insurance field assignments are identical. + */ + async initialFormVinV2( + requestId: string, + body: InitialFormVinDto, + user: any, + expectedRole?: string, + ) { + try { + const req = await this.blameRequestDbService.findById(requestId); + if (!req) throw new NotFoundException("Request not found"); + + if (!req.workflow?.currentStep) { + throw new BadRequestException("Request workflow is not initialized"); + } + + const stepKey = req.workflow.currentStep as WorkflowStep; + if ( + stepKey !== WorkflowStep.FIRST_INITIAL_FORM && + stepKey !== WorkflowStep.SECOND_INITIAL_FORM + ) { + throw new BadRequestException( + `Invalid step. Expected FIRST_INITIAL_FORM or SECOND_INITIAL_FORM but got ${stepKey}`, + ); + } + + const role = this.stepKeyToPartyRole(stepKey); + const idx = this.getPartyIndex(req, role); + if (idx === -1) + throw new BadRequestException(`${role} party not found on request`); + + const party = req.parties[idx]; + this.assertPartyOwner( + party, + user, + `Only ${role} party can submit this step`, + req, + ); + this.assertExpectedPartyRole( + expectedRole, + role, + this.isBlameOnBehalfActor(req, user), + ); + + if (!party.person) party.person = {} as any; + if ( + !party.person.userId && + user?.sub && + !this.isBlameOnBehalfActor(req, user) + ) { + party.person.userId = Types.ObjectId.isValid(user.sub) + ? new Types.ObjectId(user.sub) + : undefined; + } + + // Validation: driver/insurer sameness rules (identical to plate path) + if (body.driverIsInsurer === false) { + if ( + body.nationalCodeOfInsurer === body.nationalCodeOfDriver || + body.insurerLicense === body.driverLicense + ) { + throw new BadRequestException( + "Insurer and Driver should be two different persons in this mode.", + ); + } + } else if (body.driverIsInsurer === true) { + const sameNat = + body.nationalCodeOfInsurer === body.nationalCodeOfDriver; + const sameLic = body.insurerLicense === body.driverLicense; + const sameBirthday = + body.driverBirthday == null || + String(body.driverBirthday) === String(body.insurerBirthday); + if (!sameNat || !sameLic || !sameBirthday) { + throw new BadRequestException( + "When driverIsInsurer is true, insurer and driver data must be the same.", + ); + } + } + + // ---- External inquiry: ESG policyByChassis ---- + const inquiryClientId = party.person?.clientId + ? String(party.person.clientId) + : undefined; + const inquiryOptions = inquiryClientId + ? { clientId: inquiryClientId } + : undefined; + + let inquiryRaw: any; + let inquiryMapped: any; + try { + const inquiry = await this.sandHubService.getPolicyByChassisInquiry( + body.vin, + inquiryOptions, + ); + inquiryRaw = inquiry.raw; + inquiryMapped = inquiry.mapped; + this.logger.log( + `[ESG] policyByChassis raw for request=${req._id}: ${JSON.stringify(inquiryRaw)}`, + ); + this.logger.log( + `[ESG] policyByChassis mapped for request=${req._id}: ${JSON.stringify(inquiryMapped)}`, + ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, true, { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + } catch (err: any) { + this.logger.error( + `[ESG] policyByChassis failed for request=${req._id}: ${err?.message || err}`, + ); + if (err?.response) { + this.logger.error( + `[ESG] policyByChassis response for request=${req._id}: status=${ + err.response.status + }, data=${JSON.stringify(err.response.data)}`, + ); + } + this.recordPartyCaseInquiryStatus( + req, + "thirdParty", + role, + false, + {}, + err, + ); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException("VIN inquiry failed", HttpStatus.BAD_REQUEST); + } + + if (inquiryMapped?.Error) { + this.logger.warn( + `[ESG] policyByChassis error for request=${req._id}: ${JSON.stringify(inquiryMapped.Error)}`, + ); + this.recordPartyCaseInquiryStatus(req, "thirdParty", role, false, { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException( + inquiryMapped.Error.Message || "VIN inquiry returned error", + HttpStatus.BAD_REQUEST, + ); + } + + // ---- External inquiry 2: personal identity (same as plate path) ---- + const personalNationalCode = + body.nationalCodeOfInsurer || body.nationalCodeOfDriver; + const personalBirthDate: number | string | null = (body.insurerBirthday ?? + body.driverBirthday ?? + null) as number | string | null; + if ( + !personalNationalCode || + personalBirthDate === null || + personalBirthDate === undefined || + String(personalBirthDate).trim() === "" + ) { + throw new BadRequestException( + "Valid nationalCode and birthDate are required for personal inquiry.", + ); + } + + try { + const personalInquiry = await this.sandHubService.getPersonalInquiry( + personalNationalCode, + personalBirthDate, + inquiryOptions, + ); + this.recordPartyCaseInquiryStatus( + req, + "person", + role, + true, + personalInquiry, + ); + this.logger.log( + `[SANDHUB] personal inquiry success request=${req._id} nationalCode=${personalNationalCode}: ${JSON.stringify(personalInquiry)}`, + ); + } catch (err: any) { + this.logger.error( + `[SANDHUB] personal inquiry failed request=${req._id} nationalCode=${personalNationalCode}: ${err?.message || err}`, + ); + this.recordPartyCaseInquiryStatus(req, "person", role, false, {}, err); + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { inquiries: req.inquiries }, + }); + throw new HttpException( + "Personal identity inquiry failed", + HttpStatus.BAD_REQUEST, + ); + } + + // Resolve insurer client from inquiry response + const clientName = inquiryMapped?.CompanyName; + if (!clientName) { + throw new BadRequestException( + "CompanyName missing from VIN inquiry response", + ); + } + const companyCode = inquiryMapped?.CompanyCode; + const client = await this.clientService.findOrCreateClientByCompanyCode( + companyCode, + clientName, + ); + if (!client) { + throw new BadRequestException( + "CompanyCode missing or invalid in VIN inquiry response", + ); + } + + // Persist party data + const resolvedClientId = + (client as any)?._id ?? (client as any)?._doc?._id; + party.person.clientId = resolvedClientId; + party.person.nationalCodeOfInsurer = body.nationalCodeOfInsurer; + party.person.nationalCodeOfDriver = body.nationalCodeOfDriver; + party.person.insurerLicense = body.insurerLicense; + party.person.driverLicense = body.driverLicense; + party.person.driverIsInsurer = body.driverIsInsurer; + party.person.isNewCar = body.isNewCar; + party.person.userNoCertificate = body.userNoCertificate; + party.person.insurerBirthday = body.insurerBirthday; + party.person.driverBirthday = + body.driverBirthday ?? + (body.driverIsInsurer ? String(body.insurerBirthday) : null); + + if (!party.vehicle) party.vehicle = {} as any; + party.vehicle.isNew = body.isNewCar; + // Store VIN as the vehicle identifier instead of plate + party.vehicle.plateId = body.vin; + party.vehicle.name = inquiryMapped?.MapTypNam; + party.vehicle.type = `${inquiryMapped?.UsageField} / ${inquiryMapped?.MapUsageName || "-"}`; + party.vehicle.inquiry = { + source: "ESG_VIN_INQUIRY", + raw: inquiryRaw, + mapped: inquiryMapped, + }; + + if (!party.insurance) party.insurance = {} as any; + party.insurance.policyNumber = inquiryMapped?.PrntPlcyCmpDocNo; + party.insurance.company = clientName; + party.insurance.financialCeiling = inquiryMapped?.FinancialCvrCptl; + party.insurance.startDate = inquiryMapped?.IssueDate; + party.insurance.endDate = inquiryMapped?.EndDate; + + // Advance workflow + await this.advanceWorkflowToNext(req, stepKey); + + const historyEntry: any = { + type: `${stepKey}_SUBMITTED`, + actor: { + actorId: Types.ObjectId.isValid(user?.sub) + ? new Types.ObjectId(user.sub) + : undefined, + actorName: user?.fullName, + actorType: "user", + }, + metadata: { + role, + companyCode, + companyName: clientName, + inquiryType: "VIN", + vin: body.vin, + }, + }; + + await this.blameRequestDbService.findByIdAndUpdate(req._id, { + $set: { + [`parties.${idx}.person`]: party.person, + [`parties.${idx}.vehicle`]: party.vehicle, + [`parties.${idx}.insurance`]: party.insurance, + inquiries: req.inquiries, + "workflow.completedSteps": req.workflow.completedSteps, + "workflow.currentStep": req.workflow.currentStep, + "workflow.nextStep": req.workflow.nextStep, + status: req.status, + }, + $push: { history: historyEntry }, + }); + + return { + requestId: req._id, + publicId: req.publicId, + workflow: req.workflow, + blameStatus: req.blameStatus, + status: req.status, + }; + } catch (error) { + throw error; + } + } + + async addDetailLocationV2( requestId: string, body: LocationDto, diff --git a/src/request-management/request-management.v2.controller.ts b/src/request-management/request-management.v2.controller.ts index e183685..d3671ac 100644 --- a/src/request-management/request-management.v2.controller.ts +++ b/src/request-management/request-management.v2.controller.ts @@ -40,6 +40,7 @@ import { BlameConfessionDtoV2, CreateBlameRequestDtoV2, DescriptionDto, + InitialFormVinDto, LocationDto, CarBodyFormDto, } from "./dto/create-request-management.dto"; @@ -197,6 +198,26 @@ export class RequestManagementV2Controller { 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") @ApiParam({ name: "requestId" }) @ApiBody({ type: LocationDto }) diff --git a/src/sand-hub/sand-hub.service.ts b/src/sand-hub/sand-hub.service.ts index 99d342b..9bad94b 100644 --- a/src/sand-hub/sand-hub.service.ts +++ b/src/sand-hub/sand-hub.service.ts @@ -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( url: string, payload: any, From a7fe04c032e7edd5ec725b1147a53441b87ff397 Mon Sep 17 00:00:00 2001 From: SepehrYahyaee <7heycallmegray@gmail.com> Date: Sat, 11 Jul 2026 17:51:14 +0330 Subject: [PATCH 2/2] YARA-986 --- src/Types&Enums/role.enum.ts | 1 + src/app.module.ts | 2 + .../actor/actor.auth.controller.ts | 10 +- src/auth/auth-services/actor.auth.service.ts | 9 + src/auth/auth.module.ts | 8 + src/auth/guards/actor-local.guard.ts | 1 + src/client/client.controller.ts | 27 ++- src/super-admin/dto/super-admin.dto.ts | 76 ++++++++ .../db-service/super-admin.db.service.ts | 74 ++++++++ .../entities/schema/super-admin.schema.ts | 30 ++++ src/super-admin/guards/super-admin.guard.ts | 49 ++++++ src/super-admin/super-admin.controller.ts | 162 ++++++++++++++++++ src/super-admin/super-admin.module.ts | 34 ++++ src/super-admin/super-admin.service.ts | 102 +++++++++++ .../system-settings.controller.ts | 4 +- 15 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 src/super-admin/dto/super-admin.dto.ts create mode 100644 src/super-admin/entities/db-service/super-admin.db.service.ts create mode 100644 src/super-admin/entities/schema/super-admin.schema.ts create mode 100644 src/super-admin/guards/super-admin.guard.ts create mode 100644 src/super-admin/super-admin.controller.ts create mode 100644 src/super-admin/super-admin.module.ts create mode 100644 src/super-admin/super-admin.service.ts diff --git a/src/Types&Enums/role.enum.ts b/src/Types&Enums/role.enum.ts index 9b6439b..c71b816 100644 --- a/src/Types&Enums/role.enum.ts +++ b/src/Types&Enums/role.enum.ts @@ -8,4 +8,5 @@ export enum RoleEnum { USER = "user", FILE_MAKER = "file_maker", FILE_REVIEWER = "file_reviewer", + SUPER_ADMIN = "super_admin", } diff --git a/src/app.module.ts b/src/app.module.ts index 0652b14..3d42844 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ import { CronModule } from "./utils/cron/cron.module"; import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module"; import { DatabaseModule } from "./core/database/database.module"; import { AppConfigModule } from "./core/config/config.module"; +import { SuperAdminModule } from "./super-admin/super-admin.module"; @Module({ imports: [ @@ -59,6 +60,7 @@ import { AppConfigModule } from "./core/config/config.module"; ExpertInsurerModule, LookupsModule, WorkflowStepManagementModule, + SuperAdminModule, ], controllers: [], providers: [ diff --git a/src/auth/auth-controllers/actor/actor.auth.controller.ts b/src/auth/auth-controllers/actor/actor.auth.controller.ts index 10208ac..3cbd777 100644 --- a/src/auth/auth-controllers/actor/actor.auth.controller.ts +++ b/src/auth/auth-controllers/actor/actor.auth.controller.ts @@ -37,6 +37,7 @@ import { LegalRegisterDto, } from "src/auth/dto/actor/register.actor.dto"; 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 { CurrentUser } from "src/decorators/user.decorator"; @@ -95,6 +96,7 @@ export class ActorAuthController { * will be removed in a future release. */ @Post("register/genuine") + @UseGuards(SuperAdminGuard) @ApiOperation({ deprecated: true, summary: "[DEPRECATED] Genuine actor registration", @@ -111,6 +113,7 @@ export class ActorAuthController { * will be removed in a future release. */ @Post("register/legal") + @UseGuards(SuperAdminGuard) @ApiOperation({ deprecated: true, summary: "[DEPRECATED] Legal actor registration", @@ -123,21 +126,24 @@ export class ActorAuthController { } @Post("register/insurer") + @UseGuards(SuperAdminGuard) @ApiBody({ type: InsurerRegisterDto }) async registerInsurer(@Body() body: InsurerRegisterDto) { return await this.actorAuthService.insurerRegister(body); } - /** Mock: create a field expert for testing. Make private later. */ + /** Requires super-admin token. */ @Post("create-field-expert") + @UseGuards(SuperAdminGuard) @ApiBody({ type: CreateFieldExpertDto }) @ApiAcceptedResponse() async createFieldExpert(@Body() body: CreateFieldExpertDto) { return await this.actorAuthService.createFieldExpertMock(body); } - /** Mock: create a registrar for testing. Make private later. */ + /** Requires super-admin token. */ @Post("create-registrar") + @UseGuards(SuperAdminGuard) @ApiBody({ type: CreateRegistrarDto }) @ApiAcceptedResponse() async createRegistrar(@Body() body: CreateRegistrarDto) { diff --git a/src/auth/auth-services/actor.auth.service.ts b/src/auth/auth-services/actor.auth.service.ts index 185950c..3d87a8c 100644 --- a/src/auth/auth-services/actor.auth.service.ts +++ b/src/auth/auth-services/actor.auth.service.ts @@ -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 { HashService } from "src/utils/hash/hash.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, keys: string[]) { const out: Record = {}; @@ -56,6 +57,7 @@ export class ActorAuthService { private readonly captchaChallengeService: CaptchaChallengeService, private readonly fileMakerDbService: FileMakerDbService, private readonly fileReviewerDbService: FileReviewerDbService, + private readonly superAdminDbService: SuperAdminDbService, ) {} // TODO convrt to class for dynamic controller @@ -114,6 +116,13 @@ export class ActorAuthService { res = await this.fileReviewerDbService.findByLoginIdentifier(username); 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: return null; } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 5a5de8b..ab512e9 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -29,6 +29,11 @@ import { UsersModule } from "src/users/users.module"; import { HashModule } from "src/utils/hash/hash.module"; import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.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). */ @Global() @@ -47,6 +52,7 @@ import { CaptchaModule } from "src/captcha/captcha.module"; schema: ClaimRequestManagementSchema, }, { name: ClaimCase.name, schema: ClaimCaseSchema }, + { name: SuperAdminModel.name, schema: SuperAdminSchema }, ]), JwtModule.register({ signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV @@ -61,6 +67,7 @@ import { CaptchaModule } from "src/captcha/captcha.module"; JwtService, LocalActorAuthGuard, LocalUserAuthGuard, + SuperAdminDbService, ], exports: [ UserAuthService, @@ -68,6 +75,7 @@ import { CaptchaModule } from "src/captcha/captcha.module"; JwtService, LocalActorAuthGuard, LocalUserAuthGuard, + SuperAdminDbService, ], controllers: [UserAuthController, ActorAuthController], }) diff --git a/src/auth/guards/actor-local.guard.ts b/src/auth/guards/actor-local.guard.ts index 9c8dc2d..a130590 100644 --- a/src/auth/guards/actor-local.guard.ts +++ b/src/auth/guards/actor-local.guard.ts @@ -45,6 +45,7 @@ export class LocalActorAuthGuard implements CanActivate { RoleEnum.REGISTRAR, RoleEnum.FILE_MAKER, RoleEnum.FILE_REVIEWER, + RoleEnum.SUPER_ADMIN, ].includes(payload.role) ) { throw new UnauthorizedException("User role is not authorized"); diff --git a/src/client/client.controller.ts b/src/client/client.controller.ts index 13ff777..a11b4eb 100644 --- a/src/client/client.controller.ts +++ b/src/client/client.controller.ts @@ -1,11 +1,20 @@ -import { Body, Controller, Get, Patch, Post } from "@nestjs/common"; import { + Body, + Controller, + Get, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags, } from "@nestjs/swagger"; 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 { SystemSettingsService } from "src/system-settings/system-settings.service"; import { ClientService } from "./client.service"; @@ -21,26 +30,35 @@ export class ClientController { ) {} @Post() + @UseGuards(SuperAdminGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Create a new insurer client (super-admin only)" }) async addClient(@Body() client: ClientDto) { return await this.clientService.addClient(client); } @Get() + @UseGuards(SuperAdminGuard) + @ApiBearerAuth() async getClient(@CurrentUser() user) { return await this.clientService.getClients(); } @Get("list") + @UseGuards(SuperAdminGuard) + @ApiBearerAuth() async getClientList(@CurrentUser() user) { return await this.clientService.getClientList(); } /** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */ @Patch("external-inquiries-live") + @UseGuards(SuperAdminGuard) + @ApiBearerAuth() @ApiOperation({ - summary: "Enable or disable live external inquiries", + summary: "Enable or disable live external inquiries (super-admin only)", 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({ type: SetExternalInquiriesLiveDto, @@ -52,7 +70,8 @@ export class ClientController { }, disableLive: { 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 }, }, }, diff --git a/src/super-admin/dto/super-admin.dto.ts b/src/super-admin/dto/super-admin.dto.ts new file mode 100644 index 0000000..36f9ab1 --- /dev/null +++ b/src/super-admin/dto/super-admin.dto.ts @@ -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; +} diff --git a/src/super-admin/entities/db-service/super-admin.db.service.ts b/src/super-admin/entities/db-service/super-admin.db.service.ts new file mode 100644 index 0000000..23c61f1 --- /dev/null +++ b/src/super-admin/entities/db-service/super-admin.db.service.ts @@ -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, + 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, + ): Promise { + return this.superAdminModel.findOne(filter).lean() as any; + } + + async findByLoginIdentifier(identifier: string): Promise { + const id = identifier.trim(); + return this.superAdminModel.findOne({ email: id }).lean() as any; + } + + async create(data: Partial): Promise { + return this.superAdminModel.create(data); + } + + async updateOne(filter: FilterQuery, update: any) { + return this.superAdminModel.updateOne(filter, update); + } + + async findAll(): Promise { + return this.superAdminModel.find().lean() as any; + } +} diff --git a/src/super-admin/entities/schema/super-admin.schema.ts b/src/super-admin/entities/schema/super-admin.schema.ts new file mode 100644 index 0000000..def101a --- /dev/null +++ b/src/super-admin/entities/schema/super-admin.schema.ts @@ -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; +export const SuperAdminSchema = SchemaFactory.createForClass(SuperAdminModel); diff --git a/src/super-admin/guards/super-admin.guard.ts b/src/super-admin/guards/super-admin.guard.ts new file mode 100644 index 0000000..47a4eb5 --- /dev/null +++ b/src/super-admin/guards/super-admin.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/src/super-admin/super-admin.controller.ts b/src/super-admin/super-admin.controller.ts new file mode 100644 index 0000000..d102c56 --- /dev/null +++ b/src/super-admin/super-admin.controller.ts @@ -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 }, + }); + } +} diff --git a/src/super-admin/super-admin.module.ts b/src/super-admin/super-admin.module.ts new file mode 100644 index 0000000..e175127 --- /dev/null +++ b/src/super-admin/super-admin.module.ts @@ -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 {} diff --git a/src/super-admin/super-admin.service.ts b/src/super-admin/super-admin.service.ts new file mode 100644 index 0000000..5a91545 --- /dev/null +++ b/src/super-admin/super-admin.service.ts @@ -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); + } +} diff --git a/src/system-settings/system-settings.controller.ts b/src/system-settings/system-settings.controller.ts index ff346be..5915851 100644 --- a/src/system-settings/system-settings.controller.ts +++ b/src/system-settings/system-settings.controller.ts @@ -23,7 +23,7 @@ export class SystemSettingsController { @Get() @UseGuards(SettingsJwtGuard, RolesGuard) - @Roles(RoleEnum.ADMIN, RoleEnum.COMPANY) + @Roles(RoleEnum.ADMIN, RoleEnum.COMPANY, RoleEnum.SUPER_ADMIN) @ApiOperation({ summary: "Get global system settings (external API toggles)", description: @@ -36,7 +36,7 @@ export class SystemSettingsController { @Patch() @UseGuards(SettingsJwtGuard, RolesGuard) - @Roles(RoleEnum.ADMIN) + @Roles(RoleEnum.ADMIN, RoleEnum.SUPER_ADMIN) @ApiOperation({ summary: "Update global system settings", description: