diff --git a/src/claim-request-management/claim-request-management.module.ts b/src/claim-request-management/claim-request-management.module.ts index d2c522f..a2692ab 100644 --- a/src/claim-request-management/claim-request-management.module.ts +++ b/src/claim-request-management/claim-request-management.module.ts @@ -53,10 +53,12 @@ import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard"; import { JwtModule } from "@nestjs/jwt"; import { MediaPolicyModule } from "src/media-policy/media-policy.module"; import { HttpModule } from "@nestjs/axios"; +import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module"; @Module({ imports: [ HttpModule, + FanavaranAuditModule, PublicIdModule, UsersModule, RequestManagementModule, diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 5318131..8cb3eed 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -169,6 +169,13 @@ import { getFanavaranClientProfile, resolveFanavaranClientKey, } from "src/core/config/fanavaran-client.config"; +import { FanavaranAuditService } from "src/fanavaran/fanavaran-audit.service"; +import type { FanavaranAuditSession } from "src/fanavaran/fanavaran-audit.types"; +import { + FanavaranAuditSource, + FanavaranAuditStatus, + FanavaranAuditStep, +} from "src/fanavaran/schema/fanavaran-audit-log.schema"; export interface FanavaranAutoSubmitResult { attempted: boolean; @@ -181,9 +188,6 @@ export interface FanavaranAutoSubmitResult { fanavaranResponse?: unknown; } -/** Placeholder for Fanavaran fields not yet mapped from claim/blame data. */ -const FANAVARAN_UNMAPPED_FIELD = "empty"; - @Injectable() export class ClaimRequestManagementService { private readonly logger = new Logger(ClaimRequestManagementService.name); @@ -262,6 +266,7 @@ export class ClaimRequestManagementService { private readonly publicIdService: PublicIdService, private readonly sandHubService: SandHubService, private readonly httpService: HttpService, + private readonly fanavaranAuditService: FanavaranAuditService, ) {} private requiredDocumentKeysV2(isCarBody: boolean): string[] { @@ -3358,15 +3363,15 @@ export class ClaimRequestManagementService { } private applyFanavaranDefaultFields(result: Record): void { - result.ActualPremium = FANAVARAN_UNMAPPED_FIELD; - result.ArchiveNo = FANAVARAN_UNMAPPED_FIELD; - result.AuthorityCulpritId = FANAVARAN_UNMAPPED_FIELD; + result.ActualPremium = null; + result.ArchiveNo = null; + result.AuthorityCulpritId = null; result.AccidentCulpritId = null; - result.ClaimCompletionDate = FANAVARAN_UNMAPPED_FIELD; - result.CostSeparationToDmgSections = FANAVARAN_UNMAPPED_FIELD; + result.ClaimCompletionDate = null; + result.CostSeparationToDmgSections = null; result.CouponNo = null; result.CourtArchiveNo = null; - result.CustomerFaultPercent = FANAVARAN_UNMAPPED_FIELD; + result.CustomerFaultPercent = null; result.CulpritLicenceCityId = null; result.CulpritLicenceCountryId = null; result.CulpritLicenceForeignCityName = null; @@ -3375,14 +3380,14 @@ export class ClaimRequestManagementService { result.DamagedCount = 1; result.DmgAssessorFirstCreationTime = null; result.EntryDate = null; - result.GlassBreakReasonId = FANAVARAN_UNMAPPED_FIELD; + result.GlassBreakReasonId = null; result.IsLicenseMatchWithVehicleKind = 1; result.HasOtherCulprit = 0; result.IsAccidentOutOfBorder = 0; result.IsFatalAccident = 0; - result.IsOwnerChanged = FANAVARAN_UNMAPPED_FIELD; + result.IsOwnerChanged = null; result.IsPlaqueChanged = 0; - result.IsSurplusArticleEighthLaw = FANAVARAN_UNMAPPED_FIELD; + result.IsSurplusArticleEighthLaw = null; result.PlaqueCityId = null; result.PlaqueKindId = null; result.PlaqueLeftNo = null; @@ -3397,7 +3402,7 @@ export class ClaimRequestManagementService { result.PoliceReportSerial = null; result.PolicyId = null; result.PreviousPolicyEndDate = ""; - result.StatusChangeDate = FANAVARAN_UNMAPPED_FIELD; + result.StatusChangeDate = null; result.TrackingCode = null; result.IsLicenseReplacement = 0; result.UnknownCulpritCauseId = null; @@ -3663,7 +3668,19 @@ export class ClaimRequestManagementService { appName: string; secret: string; } = this.getTejaratnoFanavaranConfig(), + auditSession?: FanavaranAuditSession, ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.GET_APP_TOKEN_URL, + requestMeta: { appName: config.appName }, + }); + } + try { const requestHeaders: any = { appname: config.appName, @@ -3699,9 +3716,34 @@ export class ClaimRequestManagementService { ); } + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.GET_APP_TOKEN_URL, + httpStatus: response.status, + responseMeta: { hasAppToken: true }, + durationMs: Date.now() - startedAt, + }); + } + this.logger.log(`Successfully obtained appToken`); return appToken; } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.GET_APP_TOKEN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.GET_APP_TOKEN_URL, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + this.logger.error("Failed to get appToken", error); if (isAxiosError(error)) { const errorMessage = @@ -3710,9 +3752,19 @@ export class ClaimRequestManagementService { error.response?.data || error.message || "Failed to get appToken from external API"; - throw new BadGatewayException(errorMessage); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + String(errorMessage), + auditSession?.trackingCode, + ), + ); } - throw new BadGatewayException("Failed to get appToken from external API"); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + "Failed to get appToken from external API", + auditSession?.trackingCode, + ), + ); } } @@ -3725,7 +3777,19 @@ export class ClaimRequestManagementService { username: string; password: string; } = this.getTejaratnoFanavaranConfig(), + auditSession?: FanavaranAuditSession, ): Promise { + const startedAt = Date.now(); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.STARTED, + requestUrl: this.LOGIN_URL, + requestMeta: { userName: config.username }, + }); + } + try { const requestHeaders: any = { appToken: appToken, @@ -3750,7 +3814,6 @@ export class ClaimRequestManagementService { }), ); - // Check response headers first (authenticationToken is in headers) const authenticationToken = response.headers.authenticationtoken || response.headers.authenticationToken || @@ -3767,9 +3830,34 @@ export class ClaimRequestManagementService { ); } + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: this.LOGIN_URL, + httpStatus: response.status, + responseMeta: { hasAuthenticationToken: true }, + durationMs: Date.now() - startedAt, + }); + } + this.logger.log(`Successfully obtained authenticationToken`); return authenticationToken; } catch (error) { + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.LOGIN, + status: FanavaranAuditStatus.FAILURE, + requestUrl: this.LOGIN_URL, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage: this.fanavaranAuditService.extractErrorMessage(error), + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } + this.logger.error("Failed to login", error); if (isAxiosError(error)) { const errorMessage = @@ -3778,9 +3866,19 @@ export class ClaimRequestManagementService { error.response?.data || error.message || "Failed to login to external API"; - throw new BadGatewayException(errorMessage); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + String(errorMessage), + auditSession?.trackingCode, + ), + ); } - throw new BadGatewayException("Failed to login to external API"); + throw new BadGatewayException( + this.fanavaranAuditService.formatErrorWithTrackingCode( + "Failed to login to external API", + auditSession?.trackingCode, + ), + ); } } @@ -3796,12 +3894,31 @@ export class ClaimRequestManagementService { location: string; }, logPrefix: string, + auditSession?: FanavaranAuditSession, ): Promise { - try { - const appToken = await this.getAppToken(config); - const authenticationToken = await this.login(appToken, config); + const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; + const startedAt = Date.now(); - const policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`; + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.STARTED, + requestUrl: policyInquiryUrl, + requestMeta: { + corpId: config.corpId, + contractId: config.contractId, + location: config.location, + nationalCode: this.fanavaranAuditService.maskNationalCode( + nationalCodeOfInsurer, + ), + }, + }); + } + + try { + const appToken = await this.getAppToken(config, auditSession); + const authenticationToken = await this.login(appToken, config, auditSession); this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, @@ -3827,15 +3944,55 @@ export class ClaimRequestManagementService { this.logger.log( `${logPrefix} Successfully retrieved PolicyId from last policy entry: ${policyId}`, ); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: policyInquiryUrl, + httpStatus: response.status, + responseMeta: { + policyId, + policyCount: response.data.length, + }, + durationMs: Date.now() - startedAt, + }); + } return policyId; } } this.logger.warn(`${logPrefix} No PolicyId found in API response`); + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.SUCCESS, + requestUrl: policyInquiryUrl, + httpStatus: response.status, + responseMeta: { + policyId: null, + policyCount: Array.isArray(response.data) ? response.data.length : 0, + }, + durationMs: Date.now() - startedAt, + }); + } return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; + if (auditSession) { + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.POLICY_INQUIRY, + status: FanavaranAuditStatus.FAILURE, + requestUrl: policyInquiryUrl, + httpStatus: isAxiosError(error) ? error.response?.status : undefined, + errorMessage, + errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), + durationMs: Date.now() - startedAt, + }); + } this.logger.warn( `${logPrefix} Policy inquiry failed; continuing with empty PolicyId: ${errorMessage}`, ); @@ -3956,10 +4113,28 @@ export class ClaimRequestManagementService { async previewFanavaranSubmitV2( claimCaseId: string, clientKey: FanavaranClientKey, - options?: { debug?: boolean }, + options?: { + debug?: boolean; + auditSession?: FanavaranAuditSession; + }, ): Promise { const profile = getFanavaranClientProfile(clientKey); const logPrefix = `[Fanavaran ${clientKey} V2] claimCaseId=${claimCaseId}`; + const auditSession = + options?.auditSession ?? + ({ + trackingCode: this.fanavaranAuditService.generateTrackingCode(), + clientKey, + source: FanavaranAuditSource.PREVIEW, + claimCaseId, + } satisfies FanavaranAuditSession); + const buildStartedAt = Date.now(); + + await this.fanavaranAuditService.recordStep({ + session: auditSession, + step: FanavaranAuditStep.BUILD_PAYLOAD, + status: FanavaranAuditStatus.STARTED, + }); try { const debug = { @@ -4088,6 +4263,7 @@ export class ClaimRequestManagementService { nationalCodeOfInsurer, profile.auth, logPrefix, + auditSession, ); debug.values.policyId = policyId; debug.steps.policyInquirySucceeded = policyId !== null; diff --git a/src/fanavaran/fanavaran-audit.module.ts b/src/fanavaran/fanavaran-audit.module.ts new file mode 100644 index 0000000..64974e0 --- /dev/null +++ b/src/fanavaran/fanavaran-audit.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { FanavaranAuditService } from "./fanavaran-audit.service"; +import { + FanavaranAuditLog, + FanavaranAuditLogSchema, +} from "./schema/fanavaran-audit-log.schema"; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: FanavaranAuditLog.name, schema: FanavaranAuditLogSchema }, + ]), + ], + providers: [FanavaranAuditService], + exports: [FanavaranAuditService], +}) +export class FanavaranAuditModule {} diff --git a/src/fanavaran/fanavaran-audit.service.ts b/src/fanavaran/fanavaran-audit.service.ts new file mode 100644 index 0000000..2ed314f --- /dev/null +++ b/src/fanavaran/fanavaran-audit.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { isAxiosError } from "axios"; +import { randomBytes } from "node:crypto"; +import { Model, Types } from "mongoose"; +import { + FanavaranAuditLog, + FanavaranAuditLogDocument, + FanavaranAuditStatus, + FanavaranAuditStep, +} from "./schema/fanavaran-audit-log.schema"; +import type { FanavaranAuditSession } from "./fanavaran-audit.types"; + +export interface RecordFanavaranAuditStepInput { + session: FanavaranAuditSession; + step: FanavaranAuditStep; + status: FanavaranAuditStatus; + requestUrl?: string; + httpStatus?: number; + requestMeta?: Record; + responseMeta?: Record; + errorMessage?: string; + errorDetails?: Record; + durationMs?: number; +} + +@Injectable() +export class FanavaranAuditService { + private readonly logger = new Logger(FanavaranAuditService.name); + + constructor( + @InjectModel(FanavaranAuditLog.name) + private readonly auditModel: Model, + ) {} + + generateTrackingCode(): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const suffix = randomBytes(3).toString("hex").toUpperCase(); + return `FNV-${date}-${suffix}`; + } + + maskNationalCode(nationalCode: string): string { + const trimmed = nationalCode.trim(); + if (trimmed.length <= 4) { + return "****"; + } + return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; + } + + sanitizeErrorDetails(error: unknown): Record { + if (isAxiosError(error)) { + const data = error.response?.data; + return { + type: "axios", + status: error.response?.status, + statusText: error.response?.statusText, + data: + typeof data === "object" && data !== null + ? data + : typeof data === "string" + ? data.slice(0, 2000) + : data, + }; + } + if (error instanceof Error) { + return { type: "error", name: error.name, message: error.message }; + } + return { type: "unknown", value: String(error) }; + } + + extractErrorMessage(error: unknown): string { + if (isAxiosError(error)) { + const data = error.response?.data as + | { Message?: string; message?: string } + | string + | undefined; + if (typeof data === "string") { + return data; + } + return ( + data?.Message || + data?.message || + error.message || + "Fanavaran request failed" + ); + } + if (error instanceof Error) { + return error.message; + } + return "Fanavaran request failed"; + } + + formatErrorWithTrackingCode(message: string, trackingCode?: string): string { + if (!trackingCode) { + return message; + } + return `${message} (trackingCode: ${trackingCode})`; + } + + async recordStep(input: RecordFanavaranAuditStepInput): Promise { + try { + await this.auditModel.create({ + trackingCode: input.session.trackingCode, + step: input.step, + status: input.status, + clientKey: input.session.clientKey, + source: input.session.source, + ...(input.session.claimCaseId + ? { claimCaseId: new Types.ObjectId(input.session.claimCaseId) } + : {}), + ...(input.session.claimRequestId + ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } + : {}), + requestUrl: input.requestUrl, + httpStatus: input.httpStatus, + requestMeta: input.requestMeta, + responseMeta: input.responseMeta, + errorMessage: input.errorMessage, + errorDetails: input.errorDetails, + durationMs: input.durationMs, + }); + } catch (error) { + this.logger.error( + `Failed to persist Fanavaran audit step ${input.step} (${input.status})`, + error, + ); + } + } + + async findByTrackingCode(trackingCode: string) { + return this.auditModel + .find({ trackingCode }) + .sort({ createdAt: 1 }) + .lean() + .exec(); + } +} diff --git a/src/fanavaran/fanavaran-audit.types.ts b/src/fanavaran/fanavaran-audit.types.ts new file mode 100644 index 0000000..43beb5f --- /dev/null +++ b/src/fanavaran/fanavaran-audit.types.ts @@ -0,0 +1,10 @@ +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; +import { FanavaranAuditSource } from "./schema/fanavaran-audit-log.schema"; + +export interface FanavaranAuditSession { + trackingCode: string; + clientKey: FanavaranClientKey; + source: FanavaranAuditSource; + claimCaseId?: string; + claimRequestId?: string; +} diff --git a/src/fanavaran/fanavaran.module.ts b/src/fanavaran/fanavaran.module.ts index ac40690..b2b4e9f 100644 --- a/src/fanavaran/fanavaran.module.ts +++ b/src/fanavaran/fanavaran.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module"; +import { FanavaranAuditModule } from "./fanavaran-audit.module"; import { FanavaranController } from "./fanavaran.controller"; @Module({ - imports: [ClaimRequestManagementModule], + imports: [ClaimRequestManagementModule, FanavaranAuditModule], controllers: [FanavaranController], }) export class FanavaranModule {} diff --git a/src/fanavaran/schema/fanavaran-audit-log.schema.ts b/src/fanavaran/schema/fanavaran-audit-log.schema.ts new file mode 100644 index 0000000..aad2666 --- /dev/null +++ b/src/fanavaran/schema/fanavaran-audit-log.schema.ts @@ -0,0 +1,75 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument, Types } from "mongoose"; +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +export enum FanavaranAuditStep { + GET_APP_TOKEN = "GET_APP_TOKEN", + LOGIN = "LOGIN", + POLICY_INQUIRY = "POLICY_INQUIRY", + BUILD_PAYLOAD = "BUILD_PAYLOAD", + SUBMIT_CLAIM = "SUBMIT_CLAIM", +} + +export enum FanavaranAuditStatus { + STARTED = "started", + SUCCESS = "success", + FAILURE = "failure", +} + +export enum FanavaranAuditSource { + PREVIEW = "preview", + SUBMIT = "submit", + AUTO_SUBMIT = "auto_submit", + LEGACY_SUBMIT = "legacy_submit", +} + +@Schema({ collection: "fanavaranAuditLogs", timestamps: true }) +export class FanavaranAuditLog { + @Prop({ type: String, required: true, index: true }) + trackingCode: string; + + @Prop({ type: String, required: true, enum: FanavaranAuditStep, index: true }) + step: FanavaranAuditStep; + + @Prop({ type: String, required: true, enum: FanavaranAuditStatus, index: true }) + status: FanavaranAuditStatus; + + @Prop({ type: String, required: true, index: true }) + clientKey: FanavaranClientKey; + + @Prop({ type: String, required: true, enum: FanavaranAuditSource, index: true }) + source: FanavaranAuditSource; + + @Prop({ type: Types.ObjectId, required: false, index: true }) + claimCaseId?: Types.ObjectId; + + @Prop({ type: Types.ObjectId, required: false, index: true }) + claimRequestId?: Types.ObjectId; + + @Prop({ type: String, required: false }) + requestUrl?: string; + + @Prop({ type: Number, required: false }) + httpStatus?: number; + + @Prop({ type: Object, required: false }) + requestMeta?: Record; + + @Prop({ type: Object, required: false }) + responseMeta?: Record; + + @Prop({ type: String, required: false }) + errorMessage?: string; + + @Prop({ type: Object, required: false }) + errorDetails?: Record; + + @Prop({ type: Number, required: false }) + durationMs?: number; +} + +export type FanavaranAuditLogDocument = HydratedDocument; +export const FanavaranAuditLogSchema = + SchemaFactory.createForClass(FanavaranAuditLog); + +FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });