diff --git a/src/claim-request-management/claim-request-management.service.ts b/src/claim-request-management/claim-request-management.service.ts index 2ed849d..2f14c3e 100644 --- a/src/claim-request-management/claim-request-management.service.ts +++ b/src/claim-request-management/claim-request-management.service.ts @@ -4290,20 +4290,24 @@ export class ClaimRequestManagementService { 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 requestMeta = { + corpId: config.corpId, + contractId: config.contractId, + location: config.location, + nationalCode: this.fanavaranAuditService.maskNationalCode( + nationalCodeOfInsurer, + ), + InsuranceLineId: 5, + }; + 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, - ), - }, + requestMethod: "GET", + requestMeta, }); } @@ -4313,6 +4317,10 @@ export class ClaimRequestManagementService { clientKey, auditSession, ); + const requestHeaders = { + ...headers, + "Content-Type": "application/json", + }; this.logger.log( `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, @@ -4320,10 +4328,7 @@ export class ClaimRequestManagementService { const response = await firstValueFrom( this.httpService.get(policyInquiryUrl, { - headers: { - ...headers, - "Content-Type": "application/json", - }, + headers: requestHeaders, timeout: 15000, }), ); @@ -4351,17 +4356,27 @@ export class ClaimRequestManagementService { this.fanavaranAuthService.clearBackoff(clientKey); if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.SUCCESS, requestUrl: policyInquiryUrl, + requestMethod: "GET", httpStatus: response.status, + requestHeaders: exchange.requestHeaders, + requestMeta, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { policyId: selectedPolicy.policyId, policyEndDate: selectedPolicy.endDate, policyEndDateGregorian: selectedPolicy.endDateGregorian, policyCount, + fromCache: false, }, durationMs: Date.now() - startedAt, }); @@ -4372,12 +4387,19 @@ export class ClaimRequestManagementService { const errorMessage = error instanceof Error ? error.message : "unknown policy inquiry error"; if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.POLICY_INQUIRY, status: FanavaranAuditStatus.FAILURE, requestUrl: policyInquiryUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "GET", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestMeta, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage, errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -4624,7 +4646,9 @@ export class ClaimRequestManagementService { } const cachedPolicyId = - (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? null; + (claimCase as any)?.fanavaranSync?.baseClaim?.policyId ?? + (claimCase as any)?.fanavaranSync?.baseClaim?.lastPayload?.PolicyId ?? + null; const payload = await this.buildFanavaranSubmitPayload({ accidentReason: selectedAccidentReason, @@ -4674,6 +4698,7 @@ export class ClaimRequestManagementService { if (cachedPolicyId != null && !forceRefreshPolicy) { debug.steps.policyIdFromCache = true; debug.values.policyId = Number(cachedPolicyId); + // Silent reuse — no Fanavaran HTTP and no audit noise on warm preview. return Number(cachedPolicyId); } @@ -5018,7 +5043,37 @@ export class ClaimRequestManagementService { } const profile = getFanavaranClientProfile(clientKey); - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId) { + try { + await this.ensureFanavaranBaseClaim( + claimCaseId, + clientKey, + FanavaranAuditSource.SUBMIT, + ); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + } catch (error) { + const warning = this.extractFanavaranErrorMessage(error); + return { + clientKey, + claimCaseId, + totalLocalImages: candidates.length, + skippedAlreadySubmitted: candidates.length - pending.length, + attempted: 0, + submitted: 0, + failed: 0, + skipped: pending.length, + warning, + results: pending.map((c) => ({ + attempted: false, + submitted: false, + skipped: true, + skipReason: warning, + fileName: c.fileName, + })), + }; + } + } if (!claimCase?.claimId) { return { clientKey, @@ -5219,13 +5274,14 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.STARTED, requestUrl: url, + requestMethod: "POST", + requestBody: content, requestMeta: { claimId: claimCase.claimId, claimNo: claimCase.claimNo, dmgCaseId: claimCase.dmgCaseId, fileName, source: file.source, - content, }, }); @@ -5234,18 +5290,25 @@ export class ClaimRequestManagementService { content, [{ path: filePath, fileName }], clientKey, + auditSession, ); this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); const fileId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_ATTACHMENT, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: content, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, fileId, fileName }, durationMs: Date.now() - startedAt, }); @@ -5693,6 +5756,11 @@ export class ClaimRequestManagementService { bodyOverride?: Record, ): Promise { try { + await this.ensureFanavaranDamageCase( + claimCaseId, + clientKey, + FanavaranAuditSource.SUBMIT, + ); const claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) throw new NotFoundException("Claim case not found"); let payload: Record; @@ -5728,12 +5796,30 @@ export class ClaimRequestManagementService { payload: Record, auditSource: FanavaranAuditSource, ): Promise { - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId || claimCase.dmgCaseId == null) { + await this.ensureFanavaranDamageCase( + claimCaseId, + clientKey, + auditSource, + ); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + } if (!claimCase?.claimId) { throw new BadRequestException( "Fanavaran claimId is required before submitting expertise", ); } + if (claimCase.expertiseId != null) { + this.logger.log( + `[executeFanavaranExpertiseSubmit] Already have expertiseId=${claimCase.expertiseId}; skipping Fanavaran POST`, + ); + return ( + (claimCase as any)?.fanavaranSync?.expertise?.response ?? { + Id: claimCase.expertiseId, + } + ); + } const url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`; const startedAt = Date.now(); const auditSession: FanavaranAuditSession = { @@ -5748,19 +5834,32 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.STARTED, requestUrl: url, - requestMeta: { claimId: claimCase.claimId, payload }, + requestMethod: "POST", + requestBody: payload, + requestMeta: { claimId: claimCase.claimId }, }); try { - const response = await this.postFanavaranJson(url, payload, clientKey); + const response = await this.postFanavaranJson( + url, + payload, + clientKey, + auditSession, + ); const expertiseId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: claimCase.claimId, expertiseId }, durationMs: Date.now() - startedAt, }); @@ -5781,12 +5880,17 @@ export class ClaimRequestManagementService { return response.data; } catch (error) { + const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_EXPERTISE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -5952,13 +6056,104 @@ export class ClaimRequestManagementService { return null; } + /** + * Soft-ensure Fanavaran base claim exists before a later stage (damage / + * attachments / expertise). Reuses claimId when present; otherwise submits + * GEN.03 once. Does not invent a fake claimId on failure. + */ + private async ensureFanavaranBaseClaim( + claimCaseId: string, + clientKey: FanavaranClientKey, + auditSource: FanavaranAuditSource, + ): Promise<{ claimId: number; claimNo?: string; created: boolean }> { + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (!existing) { + throw new NotFoundException("Claim case not found"); + } + if (existing.claimId != null) { + return { + claimId: Number(existing.claimId), + claimNo: + existing.claimNo != null ? String(existing.claimNo) : undefined, + created: false, + }; + } + + this.logger.log( + `[Fanavaran ${clientKey}] Soft-ensuring base claim before next stage claimCaseId=${claimCaseId}`, + ); + await this.executeFanavaranV2Submit( + claimCaseId, + clientKey, + undefined, + auditSource, + ); + + const refreshed = await this.claimCaseDbService.findById(claimCaseId); + if (refreshed?.claimId == null) { + throw new BadRequestException( + "Fanavaran base claim is missing and could not be created before the next stage. Fix base claim first.", + ); + } + return { + claimId: Number(refreshed.claimId), + claimNo: + refreshed.claimNo != null ? String(refreshed.claimNo) : undefined, + created: true, + }; + } + + /** + * Soft-ensure damage case exists (creates base claim first if needed). + */ + private async ensureFanavaranDamageCase( + claimCaseId: string, + clientKey: FanavaranClientKey, + auditSource: FanavaranAuditSource, + selectedParts?: unknown[], + ): Promise<{ claimId: number; dmgCaseId: number; created: boolean }> { + await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); + + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (existing?.dmgCaseId != null && existing.claimId != null) { + return { + claimId: Number(existing.claimId), + dmgCaseId: Number(existing.dmgCaseId), + created: false, + }; + } + + this.logger.log( + `[Fanavaran ${clientKey}] Soft-ensuring damage case before next stage claimCaseId=${claimCaseId}`, + ); + await this.executeFanavaranDamageCaseSubmit( + claimCaseId, + clientKey, + selectedParts ?? existing?.damage?.selectedParts ?? [], + auditSource, + ); + + const refreshed = await this.claimCaseDbService.findById(claimCaseId); + if (refreshed?.claimId == null || refreshed.dmgCaseId == null) { + throw new BadRequestException( + "Fanavaran damage case is missing and could not be created before the next stage.", + ); + } + return { + claimId: Number(refreshed.claimId), + dmgCaseId: Number(refreshed.dmgCaseId), + created: true, + }; + } + private async postFanavaranJson( url: string, payload: Record, clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); try { const response = await firstValueFrom( @@ -5982,9 +6177,10 @@ export class ClaimRequestManagementService { content: Record, files: Array<{ path: string; fileName: string }>, clientKey: FanavaranClientKey, + auditSession?: FanavaranAuditSession, ) { this.fanavaranAuthService.assertNotInBackoff(clientKey); - const headers = await this.getFanavaranAuthHeaders(clientKey); + const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession); const form = new FormData(); form.append("Param", JSON.stringify(content), { @@ -6043,13 +6239,27 @@ export class ClaimRequestManagementService { bodyOverride?: Record, ): Promise { const profile = getFanavaranClientProfile(clientKey); - const claimCase = await this.claimCaseDbService.findById(claimCaseId); + let claimCase = await this.claimCaseDbService.findById(claimCaseId); if (!claimCase) { throw new NotFoundException("Claim case not found"); } if (!claimCase.claimId) { - throw new BadRequestException( - "Fanavaran claimId is required before submitting damage case", + await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource); + claimCase = await this.claimCaseDbService.findById(claimCaseId); + if (!claimCase?.claimId) { + throw new BadRequestException( + "Fanavaran claimId is required before submitting damage case", + ); + } + } + if (claimCase.dmgCaseId != null && !bodyOverride) { + this.logger.log( + `[executeFanavaranDamageCaseSubmit] Already have dmgCaseId=${claimCase.dmgCaseId}; skipping Fanavaran POST`, + ); + return ( + (claimCase as any)?.fanavaranSync?.damageCase?.response ?? { + Id: claimCase.dmgCaseId, + } ); } if (!claimCase.blameRequestId) { @@ -6092,19 +6302,32 @@ export class ClaimRequestManagementService { step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.STARTED, requestUrl: url, - requestMeta: { claimId: claimCase.claimId, payload }, + requestMethod: "POST", + requestBody: payload, + requestMeta: { claimId: claimCase.claimId }, }); try { - const response = await this.postFanavaranJson(url, payload, clientKey); + const response = await this.postFanavaranJson( + url, + payload, + clientKey, + auditSession, + ); const dmgCaseId = response.data?.Id; + const exchange = + this.fanavaranAuditService.captureAxiosExchange(response); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.SUCCESS, requestUrl: url, + requestMethod: "POST", httpStatus: response.status, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { dmgCaseId, claimId: claimCase.claimId }, durationMs: Date.now() - startedAt, }); @@ -6134,12 +6357,17 @@ export class ClaimRequestManagementService { this.logger.error( `[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`, ); + const exchange = this.fanavaranAuditService.captureAxiosExchange(error); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, status: FanavaranAuditStatus.FAILURE, requestUrl: url, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestBody: payload, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -6830,6 +7058,19 @@ export class ClaimRequestManagementService { `${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, ); + const existing = await this.claimCaseDbService.findById(claimCaseId); + if (existing?.claimId != null && !bodyOverride) { + this.logger.log( + `${logPrefix} Base claim already exists claimId=${existing.claimId}; skipping Fanavaran POST`, + ); + return ( + (existing as any)?.fanavaranSync?.baseClaim?.response ?? { + Id: existing.claimId, + ClaimNo: existing.claimNo, + } + ); + } + const auditSession: FanavaranAuditSession = { trackingCode: this.fanavaranAuditService.generateTrackingCode(), clientKey, @@ -6868,11 +7109,18 @@ export class ClaimRequestManagementService { auditSession, ); + const requestHeaders = { + ...headers, + "Content-Type": "application/json", + }; await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.STARTED, requestUrl: this.FANAVARAN_SUBMIT_URL, + requestMethod: "POST", + requestHeaders, + requestBody: fanavaranData, requestMeta: { policyId: fanavaranData?.PolicyId ?? null }, }); const startedAt = Date.now(); @@ -6880,10 +7128,7 @@ export class ClaimRequestManagementService { try { const response = await firstValueFrom( this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { - headers: { - ...headers, - "Content-Type": "application/json", - }, + headers: requestHeaders, }), ); @@ -6895,12 +7140,21 @@ export class ClaimRequestManagementService { this.fanavaranAuthService.clearBackoff(clientKey); + const exchange = this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.FANAVARAN_SUBMIT_URL, + requestMethod: "POST", httpStatus: response.status, + requestHeaders: exchange.requestHeaders, + requestBody: fanavaranData, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, responseMeta: { claimId: response.data?.Id, claimNo: response.data?.ClaimNo, @@ -6946,12 +7200,21 @@ export class ClaimRequestManagementService { return response.data; } catch (error) { this.fanavaranAuthService.registerFailure(clientKey, error); + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.SUBMIT_CLAIM, status: FanavaranAuditStatus.FAILURE, requestUrl: this.FANAVARAN_SUBMIT_URL, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: fanavaranData, + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, diff --git a/src/core/config/fanavaran-client.config.ts b/src/core/config/fanavaran-client.config.ts index 48a4406..52f8e23 100644 --- a/src/core/config/fanavaran-client.config.ts +++ b/src/core/config/fanavaran-client.config.ts @@ -1,20 +1,26 @@ -export type FanavaranClientKey = "parsian" | "tejaratno"; +export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem"; export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [ "parsian", "tejaratno", + "moallem", ] as const; +/** Swagger `@ApiParam({ enum })` value — keep in sync with {@link FANAVARAN_CLIENT_KEYS}. */ +export const FANAVARAN_CLIENT_SWAGGER_ENUM: FanavaranClientKey[] = [ + ...FANAVARAN_CLIENT_KEYS, +]; + export function isFanavaranClientKey( value: string, ): value is FanavaranClientKey { const normalized = value?.trim().toLowerCase(); - return normalized === "parsian" || normalized === "tejaratno"; + return (FANAVARAN_CLIENT_KEYS as readonly string[]).includes(normalized); } export function normalizeFanavaranClientKey(value: string): FanavaranClientKey { const normalized = value?.trim().toLowerCase(); - if (normalized === "parsian" || normalized === "tejaratno") { + if (isFanavaranClientKey(normalized)) { return normalized; } throw new Error( @@ -56,6 +62,29 @@ export interface FanavaranClientProfile { defaults: FanavaranPayloadDefaults; } +/** + * Shared codebook-ish defaults used when a tenant has not supplied its own + * ClaimExpertId / plaque ids yet. Moallem auth is real; ClaimExpertId may need + * a Moallem-specific value from Fanavaran lookups after first deploy. + */ +const SHARED_FANAVARAN_DEFAULTS: FanavaranPayloadDefaults = { + AccidentCityId: 701, + AccidentReportTypeId: 155, + AccidentVehicleUsedId: 1, + ClaimExpertId: 4543092, + ExpertiseClaimExpertId: 4543092, + CompensationReferenceId: 167, + CulpritLicenceTypeId: 2, + CulpritTypeId: 337, + DmgCaseTypeId: 175, + DmgHistoryStatus: 5214, + PlaqueKindId: 8, + PlaqueSampleId: 10, + DriverIsOwner: 0, + FaultPercent: 100, + ClaimFileTypeId: 23, +}; + const FANAVARAN_CLIENT_PROFILES: Record< FanavaranClientKey, FanavaranClientProfile @@ -72,20 +101,9 @@ const FANAVARAN_CLIENT_PROFILES: Record< location: "100", }, defaults: { - AccidentCityId: 701, - AccidentReportTypeId: 155, - AccidentVehicleUsedId: 1, + ...SHARED_FANAVARAN_DEFAULTS, ClaimExpertId: 4543092, ExpertiseClaimExpertId: 4543092, - CompensationReferenceId: 167, - CulpritLicenceTypeId: 2, - CulpritTypeId: 337, - DmgCaseTypeId: 175, - DmgHistoryStatus: 5214, - PlaqueKindId: 8, - PlaqueSampleId: 10, - DriverIsOwner: 0, - FaultPercent: 100, ClaimFileTypeId: 23, }, }, @@ -101,29 +119,35 @@ const FANAVARAN_CLIENT_PROFILES: Record< location: "210050", }, defaults: { - AccidentCityId: 701, - AccidentReportTypeId: 155, - AccidentVehicleUsedId: 1, + ...SHARED_FANAVARAN_DEFAULTS, ClaimExpertId: 154, ExpertiseClaimExpertId: 29, - CompensationReferenceId: 167, - CulpritLicenceTypeId: 2, - CulpritTypeId: 337, - DmgCaseTypeId: 175, - DmgHistoryStatus: 5214, - PlaqueKindId: 8, - PlaqueSampleId: 10, - DriverIsOwner: 0, - FaultPercent: 100, ClaimFileTypeId: 70, }, }, + moallem: { + key: "moallem", + auth: { + appName: "ItTalie", + secret: "itT@l!3@api", + username: "itTalieUser", + password: "itT@l!3@user", + corpId: "5650", + contractId: "304", + location: "30900", + }, + // ClaimExpertId / ClaimFileTypeId not yet confirmed for Moallem — start from + // shared Fanavaran codebook defaults and override after lookup. + defaults: { + ...SHARED_FANAVARAN_DEFAULTS, + }, + }, }; /** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */ export function resolveFanavaranClientKey(): FanavaranClientKey { const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase(); - if (explicit === "parsian" || explicit === "tejaratno") { + if (explicit && isFanavaranClientKey(explicit)) { return explicit; } diff --git a/src/fanavaran/fanavaran-audit.service.ts b/src/fanavaran/fanavaran-audit.service.ts index 2ed314f..6117cff 100644 --- a/src/fanavaran/fanavaran-audit.service.ts +++ b/src/fanavaran/fanavaran-audit.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; -import { isAxiosError } from "axios"; +import { isAxiosError, type AxiosResponse } from "axios"; import { randomBytes } from "node:crypto"; import { Model, Types } from "mongoose"; import { @@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput { step: FanavaranAuditStep; status: FanavaranAuditStatus; requestUrl?: string; + requestMethod?: string; httpStatus?: number; + requestHeaders?: Record; + requestBody?: unknown; requestMeta?: Record; + responseHeaders?: Record; + responseBody?: unknown; responseMeta?: Record; errorMessage?: string; errorDetails?: Record; durationMs?: number; } +export interface FanavaranHttpExchange { + httpStatus?: number; + requestHeaders?: Record; + responseHeaders?: Record; + responseBody?: unknown; +} + @Injectable() export class FanavaranAuditService { private readonly logger = new Logger(FanavaranAuditService.name); + /** Max serialized chars kept for request/response bodies in audit docs. */ + static readonly BODY_MAX_CHARS = 80_000; + + private static readonly SENSITIVE_HEADER_KEYS = new Set([ + "password", + "secret", + "authorization", + "authenticationtoken", + "apptoken", + "app-token", + "x-api-key", + "cookie", + "set-cookie", + ]); + + private static readonly SENSITIVE_BODY_KEYS = new Set([ + "password", + "secret", + "authenticationtoken", + "apptoken", + "token", + "files", + ]); + constructor( @InjectModel(FanavaranAuditLog.name) private readonly auditModel: Model, @@ -47,6 +83,138 @@ export class FanavaranAuditService { return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; } + maskToken(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= 8) { + return "****"; + } + return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)} (len=${trimmed.length})`; + } + + sanitizeHeaders( + headers?: Record | null, + ): Record | undefined { + if (!headers || typeof headers !== "object") { + return undefined; + } + const out: Record = {}; + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawValue === undefined) continue; + const key = String(rawKey); + const lower = key.toLowerCase(); + if (FanavaranAuditService.SENSITIVE_HEADER_KEYS.has(lower)) { + out[key] = + typeof rawValue === "string" + ? this.maskToken(rawValue) + : "***"; + continue; + } + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" || + rawValue === null + ) { + out[key] = rawValue; + } else { + out[key] = String(rawValue); + } + } + return out; + } + + sanitizeBody(body: unknown): unknown { + if (body === undefined) { + return undefined; + } + const masked = this.maskSensitiveDeep(body); + try { + const serialized = JSON.stringify(masked); + if (serialized.length <= FanavaranAuditService.BODY_MAX_CHARS) { + return masked; + } + return { + _truncated: true, + maxChars: FanavaranAuditService.BODY_MAX_CHARS, + preview: serialized.slice(0, FanavaranAuditService.BODY_MAX_CHARS), + }; + } catch { + const asString = String(masked); + return asString.length <= FanavaranAuditService.BODY_MAX_CHARS + ? asString + : { + _truncated: true, + preview: asString.slice(0, FanavaranAuditService.BODY_MAX_CHARS), + }; + } + } + + /** + * Extract request/response headers + body from an Axios response or Axios + * error (uses `config.headers` when present). Values are returned raw; + * `recordStep` applies sanitisation before persistence. + */ + captureAxiosExchange( + source: unknown, + overrideRequestHeaders?: Record, + ): FanavaranHttpExchange { + const toPlainHeaders = ( + headers?: Record | null, + ): Record | undefined => { + if (!headers || typeof headers !== "object") return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + out[key] = + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ? value + : String(value); + } + return out; + }; + + if (isAxiosError(source)) { + return { + httpStatus: source.response?.status, + requestHeaders: toPlainHeaders( + overrideRequestHeaders ?? + (source.config?.headers as Record | undefined), + ), + responseHeaders: toPlainHeaders( + source.response?.headers as Record | undefined, + ), + responseBody: source.response?.data, + }; + } + + const response = source as AxiosResponse | null; + if ( + response && + typeof response === "object" && + "status" in response && + "headers" in response + ) { + return { + httpStatus: response.status, + requestHeaders: toPlainHeaders( + overrideRequestHeaders ?? + (response.config?.headers as Record | undefined), + ), + responseHeaders: toPlainHeaders( + response.headers as Record | undefined, + ), + responseBody: response.data, + }; + } + + return { + requestHeaders: toPlainHeaders(overrideRequestHeaders), + }; + } + sanitizeErrorDetails(error: unknown): Record { if (isAxiosError(error)) { const data = error.response?.data; @@ -54,12 +222,13 @@ export class FanavaranAuditService { type: "axios", status: error.response?.status, statusText: error.response?.statusText, - data: + data: this.sanitizeBody( typeof data === "object" && data !== null ? data : typeof data === "string" ? data.slice(0, 2000) : data, + ), }; } if (error instanceof Error) { @@ -112,8 +281,13 @@ export class FanavaranAuditService { ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } : {}), requestUrl: input.requestUrl, + requestMethod: input.requestMethod, httpStatus: input.httpStatus, + requestHeaders: this.sanitizeHeaders(input.requestHeaders), + requestBody: this.sanitizeBody(input.requestBody), requestMeta: input.requestMeta, + responseHeaders: this.sanitizeHeaders(input.responseHeaders), + responseBody: this.sanitizeBody(input.responseBody), responseMeta: input.responseMeta, errorMessage: input.errorMessage, errorDetails: input.errorDetails, @@ -134,4 +308,31 @@ export class FanavaranAuditService { .lean() .exec(); } + + private maskSensitiveDeep(value: unknown, depth = 0): unknown { + if (depth > 8) { + return "[max-depth]"; + } + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => this.maskSensitiveDeep(item, depth + 1)); + } + if (typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries( + value as Record, + )) { + if (FanavaranAuditService.SENSITIVE_BODY_KEYS.has(key.toLowerCase())) { + out[key] = + typeof child === "string" ? this.maskToken(child) : "[redacted]"; + continue; + } + out[key] = this.maskSensitiveDeep(child, depth + 1); + } + return out; + } + return value; + } } diff --git a/src/fanavaran/fanavaran-auth.service.spec.ts b/src/fanavaran/fanavaran-auth.service.spec.ts index 3af85ea..2191a69 100644 --- a/src/fanavaran/fanavaran-auth.service.spec.ts +++ b/src/fanavaran/fanavaran-auth.service.spec.ts @@ -1,6 +1,43 @@ import { FanavaranAuthService } from "./fanavaran-auth.service"; describe("FanavaranAuthService", () => { + const createAuthTokenModel = () => { + const store = new Map< + string, + { clientKey: string; authenticationToken: string; expiresAt: Date } + >(); + return { + findOne: jest.fn((query: { clientKey: string }) => ({ + lean: () => ({ + exec: async () => store.get(query.clientKey) ?? null, + }), + })), + findOneAndUpdate: jest.fn( + ( + query: { clientKey: string }, + update: { $set: { authenticationToken: string; expiresAt: Date } }, + ) => ({ + exec: async () => { + const next = { + clientKey: query.clientKey, + authenticationToken: update.$set.authenticationToken, + expiresAt: update.$set.expiresAt, + }; + store.set(query.clientKey, next); + return next; + }, + }), + ), + deleteOne: jest.fn((query: { clientKey: string }) => ({ + exec: async () => { + store.delete(query.clientKey); + return { deletedCount: 1 }; + }, + })), + _store: store, + }; + }; + it("detects Fanavaran transient try-later messages", () => { expect( FanavaranAuthService.isTransientTryLaterError( @@ -24,22 +61,11 @@ describe("FanavaranAuthService", () => { e instanceof Error ? e.message : String(e), sanitizeErrorDetails: () => ({}), formatErrorWithTrackingCode: (m: string) => m, + captureAxiosExchange: () => ({}), }; + const authTokenModel = createAuthTokenModel(); let loginCalls = 0; - http.post.mockImplementation((url: string) => { - if (url.includes("GetAppToken")) { - return { - toPromise: undefined, - pipe: undefined, - subscribe: undefined, - // firstValueFrom uses Observable — mock as Observable-like via rxjs - }; - } - return {}; - }); - - // Use real firstValueFrom path by mocking httpService.post to return an Observable const { of, delay } = await import("rxjs"); http.post.mockImplementation((url: string) => { if (url.includes("GetAppToken")) { @@ -57,7 +83,11 @@ describe("FanavaranAuthService", () => { }).pipe(delay(20)); }); - const service = new FanavaranAuthService(http as any, audit as any); + const service = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); const [a, b, c] = await Promise.all([ service.getAuthenticationToken("parsian"), @@ -75,28 +105,74 @@ describe("FanavaranAuthService", () => { expect(loginCalls).toBe(1); }); + it("reuses persisted token across service instances", async () => { + const http = { post: jest.fn() }; + const audit = { + recordStep: jest.fn().mockResolvedValue(undefined), + extractErrorMessage: (e: unknown) => + e instanceof Error ? e.message : String(e), + sanitizeErrorDetails: () => ({}), + formatErrorWithTrackingCode: (m: string) => m, + captureAxiosExchange: () => ({}), + }; + const authTokenModel = createAuthTokenModel(); + const { of } = await import("rxjs"); + let loginCalls = 0; + http.post.mockImplementation((url: string) => { + if (url.includes("GetAppToken")) { + return of({ + status: 200, + headers: { apptoken: "app-1" }, + data: {}, + }); + } + loginCalls += 1; + return of({ + status: 200, + headers: { authenticationtoken: "auth-persisted" }, + data: {}, + }); + }); + + const first = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); + await first.getAuthenticationToken("tejaratno"); + expect(loginCalls).toBe(1); + + const second = new FanavaranAuthService( + http as any, + audit as any, + authTokenModel as any, + ); + const token = await second.getAuthenticationToken("tejaratno"); + expect(token).toBe("auth-persisted"); + expect(loginCalls).toBe(1); + }); + it("enters tenant backoff on try-later errors", () => { - const service = new FanavaranAuthService({} as any, {} as any); + const service = new FanavaranAuthService( + {} as any, + {} as any, + createAuthTokenModel() as any, + ); service.registerFailure( "parsian", "کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", ); expect(service.isInBackoff("parsian")).toBe(true); - expect(() => service.assertNotInBackoff("parsian")).toThrow( - /backoff/i, - ); + expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i); }); it("computes next Asia/Tehran midnight expiry after now", () => { - // 2026-08-02 10:00:00 UTC ≈ 13:30 Tehran (UTC+3:30) → same calendar day midnight const now = Date.parse("2026-08-02T10:00:00.000Z"); const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now); expect(expiry).toBeGreaterThan(now); - // Must land within ~14h (before next Tehran midnight) expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000); expect(expiry - now).toBeGreaterThan(0); - // Just after Tehran midnight: 2026-08-01 20:30:01 UTC = 2026-08-02 00:00:01 Tehran const justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z"); const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight); expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000); diff --git a/src/fanavaran/fanavaran-auth.service.ts b/src/fanavaran/fanavaran-auth.service.ts index 0ac88ec..cac9a15 100644 --- a/src/fanavaran/fanavaran-auth.service.ts +++ b/src/fanavaran/fanavaran-auth.service.ts @@ -6,7 +6,9 @@ import { Logger, ServiceUnavailableException, } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; import { isAxiosError } from "axios"; +import { Model } from "mongoose"; import { firstValueFrom } from "rxjs"; import { getFanavaranClientProfile, @@ -19,6 +21,10 @@ import { FanavaranAuditStatus, FanavaranAuditStep, } from "./schema/fanavaran-audit-log.schema"; +import { + FanavaranAuthToken, + FanavaranAuthTokenDocument, +} from "./schema/fanavaran-auth-token.schema"; interface CachedFanavaranAuth { authenticationToken: string; @@ -63,6 +69,8 @@ export class FanavaranAuthService { constructor( private readonly httpService: HttpService, private readonly fanavaranAuditService: FanavaranAuditService, + @InjectModel(FanavaranAuthToken.name) + private readonly authTokenModel: Model, ) {} /** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */ @@ -162,6 +170,12 @@ export class FanavaranAuthService { invalidateToken(clientKey: FanavaranClientKey): void { this.tokenCache.delete(clientKey); + void this.authTokenModel.deleteOne({ clientKey }).exec().catch((error) => { + this.logger.warn( + `[${clientKey}] Failed to clear persisted Fanavaran auth token`, + error, + ); + }); } assertNotInBackoff(clientKey: FanavaranClientKey): void { @@ -183,9 +197,13 @@ export class FanavaranAuthService { this.assertNotInBackoff(clientKey); if (!options?.forceRefresh) { - const cached = this.tokenCache.get(clientKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.authenticationToken; + const memoryHit = this.readMemoryCache(clientKey); + if (memoryHit) { + return memoryHit; + } + const persisted = await this.readPersistedCache(clientKey); + if (persisted) { + return persisted; } } else { this.invalidateToken(clientKey); @@ -243,10 +261,7 @@ export class FanavaranAuthService { ); const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); - this.tokenCache.set(clientKey, { - authenticationToken, - expiresAt, - }); + await this.persistToken(clientKey, authenticationToken, expiresAt); this.clearBackoff(clientKey); this.logger.log( `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( @@ -256,6 +271,76 @@ export class FanavaranAuthService { return authenticationToken; } + private readMemoryCache(clientKey: FanavaranClientKey): string | null { + const cached = this.tokenCache.get(clientKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.authenticationToken; + } + if (cached) { + this.tokenCache.delete(clientKey); + } + return null; + } + + private async readPersistedCache( + clientKey: FanavaranClientKey, + ): Promise { + try { + const doc = await this.authTokenModel.findOne({ clientKey }).lean().exec(); + if (!doc?.authenticationToken || !doc.expiresAt) { + return null; + } + const expiresAt = new Date(doc.expiresAt).getTime(); + if (!(expiresAt > Date.now())) { + await this.authTokenModel.deleteOne({ clientKey }).exec(); + return null; + } + this.tokenCache.set(clientKey, { + authenticationToken: doc.authenticationToken, + expiresAt, + }); + this.logger.log( + `[${clientKey}] Reused persisted Fanavaran authenticationToken until ${new Date( + expiresAt, + ).toISOString()}`, + ); + return doc.authenticationToken; + } catch (error) { + this.logger.warn( + `[${clientKey}] Failed to read persisted Fanavaran auth token`, + error, + ); + return null; + } + } + + private async persistToken( + clientKey: FanavaranClientKey, + authenticationToken: string, + expiresAt: number, + ): Promise { + this.tokenCache.set(clientKey, { authenticationToken, expiresAt }); + try { + await this.authTokenModel + .findOneAndUpdate( + { clientKey }, + { + $set: { + authenticationToken, + expiresAt: new Date(expiresAt), + }, + }, + { upsert: true, new: true }, + ) + .exec(); + } catch (error) { + this.logger.warn( + `[${clientKey}] Failed to persist Fanavaran auth token (memory cache still active)`, + error, + ); + } + } + private emptyBodyTransformRequest() { return [ (_data: unknown, headers?: Record) => { @@ -273,12 +358,20 @@ export class FanavaranAuthService { auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); + const requestHeaders = { + appname: config.appName, + secret: config.secret, + "Content-Length": "0", + }; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.STARTED, requestUrl: this.getAppTokenUrl, + requestMethod: "POST", + requestHeaders, + requestBody: "", requestMeta: { appName: config.appName, cached: false }, }); } @@ -286,11 +379,7 @@ export class FanavaranAuthService { try { const response = await firstValueFrom( this.httpService.post(this.getAppTokenUrl, "", { - headers: { - appname: config.appName, - secret: config.secret, - "Content-Length": "0", - }, + headers: requestHeaders, transformRequest: this.emptyBodyTransformRequest(), }), ); @@ -306,13 +395,23 @@ export class FanavaranAuthService { } if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.getAppTokenUrl, + requestMethod: "POST", httpStatus: response.status, - responseMeta: { hasAppToken: true }, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, + responseMeta: { hasAppToken: true, cached: false }, durationMs: Date.now() - startedAt, }); } @@ -320,12 +419,21 @@ export class FanavaranAuthService { return appToken; } catch (error) { if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.GET_APP_TOKEN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.getAppTokenUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, @@ -341,12 +449,21 @@ export class FanavaranAuthService { auditSession?: FanavaranAuditSession, ): Promise { const startedAt = Date.now(); + const requestHeaders = { + appToken, + userName: config.username, + password: config.password, + "Content-Length": "0", + }; if (auditSession) { await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.STARTED, requestUrl: this.loginUrl, + requestMethod: "POST", + requestHeaders, + requestBody: "", requestMeta: { userName: config.username, cached: false }, }); } @@ -354,12 +471,7 @@ export class FanavaranAuthService { try { const response = await firstValueFrom( this.httpService.post(this.loginUrl, "", { - headers: { - appToken, - userName: config.username, - password: config.password, - "Content-Length": "0", - }, + headers: requestHeaders, transformRequest: this.emptyBodyTransformRequest(), }), ); @@ -380,13 +492,26 @@ export class FanavaranAuthService { } if (auditSession) { + const exchange = + this.fanavaranAuditService.captureAxiosExchange( + response, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.SUCCESS, requestUrl: this.loginUrl, + requestMethod: "POST", httpStatus: response.status, - responseMeta: { hasAuthenticationToken: true }, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, + responseMeta: { + hasAuthenticationToken: true, + cached: false, + }, durationMs: Date.now() - startedAt, }); } @@ -394,12 +519,21 @@ export class FanavaranAuthService { return authenticationToken; } catch (error) { if (auditSession) { + const exchange = this.fanavaranAuditService.captureAxiosExchange( + error, + requestHeaders, + ); await this.fanavaranAuditService.recordStep({ session: auditSession, step: FanavaranAuditStep.LOGIN, status: FanavaranAuditStatus.FAILURE, requestUrl: this.loginUrl, - httpStatus: isAxiosError(error) ? error.response?.status : undefined, + requestMethod: "POST", + httpStatus: exchange.httpStatus, + requestHeaders: exchange.requestHeaders, + requestBody: "", + responseHeaders: exchange.responseHeaders, + responseBody: exchange.responseBody, errorMessage: this.fanavaranAuditService.extractErrorMessage(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), durationMs: Date.now() - startedAt, diff --git a/src/fanavaran/fanavaran-lookup.module.ts b/src/fanavaran/fanavaran-lookup.module.ts index 4d628bc..bc1d645 100644 --- a/src/fanavaran/fanavaran-lookup.module.ts +++ b/src/fanavaran/fanavaran-lookup.module.ts @@ -1,10 +1,15 @@ import { Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { MongooseModule } from "@nestjs/mongoose"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { FanavaranAuditModule } from "./fanavaran-audit.module"; import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service"; +import { + FanavaranAuthToken, + FanavaranAuthTokenSchema, +} from "./schema/fanavaran-auth-token.schema"; @Module({ imports: [ @@ -13,6 +18,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service"; inject: [ConfigService], useFactory: createHttpModuleOptions, }), + MongooseModule.forFeature([ + { name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema }, + ]), FanavaranAuditModule, ], providers: [FanavaranAuthService, FanavaranLookupService], diff --git a/src/fanavaran/fanavaran.controller.ts b/src/fanavaran/fanavaran.controller.ts index 99d4f4b..de58a13 100644 --- a/src/fanavaran/fanavaran.controller.ts +++ b/src/fanavaran/fanavaran.controller.ts @@ -18,6 +18,8 @@ import { import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { + FANAVARAN_CLIENT_KEYS, + FANAVARAN_CLIENT_SWAGGER_ENUM, isFanavaranClientKey, listFanavaranClientProfiles, normalizeFanavaranClientKey, @@ -37,7 +39,7 @@ export class FanavaranController { @ApiOperation({ summary: "List supported Fanavaran insurance clients", description: - "Returns configured Fanavaran tenants (parsian, tejaratno) and which client is active for this deployment.", + "Returns configured Fanavaran tenants (parsian, tejaratno, moallem) and which client is active for this deployment.", }) listClients() { const activeClient = resolveFanavaranClientKey(); @@ -60,7 +62,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -75,26 +77,33 @@ export class FanavaranController { name: "forceRefreshPolicy", required: false, description: - "When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again", + "When true, ignores cached PolicyId and performs a live Fanavaran policy inquiry again. Do not pass this from normal UI loads.", + }) + @ApiQuery({ + name: "resolvePolicy", + required: false, + deprecated: true, + description: + "Deprecated. Ignored for cache-busting. PolicyId is resolve-once from fanavaranSync.baseClaim.policyId; use forceRefreshPolicy=true only to re-inquire.", }) async preview( @Param("client") client: string, @Param("claimCaseId") claimCaseId: string, @Query("debug") debug?: string, @Query("forceRefreshPolicy") forceRefreshPolicy?: string, - @Query("resolvePolicy") resolvePolicy?: string, + @Query("resolvePolicy") _resolvePolicy?: string, ) { const clientKey = this.parseClientParam(client); + // IMPORTANT: resolvePolicy must NOT force a live inquiry. Older UI clients + // send resolvePolicy=true on every preview load; that used to defeat the + // PolicyId cache and re-Login Fanavaran on every click. return await this.claimRequestManagementService.previewFanavaranSubmitV2( claimCaseId, clientKey, { debug: debug === "1" || debug === "true", forceRefreshPolicy: - forceRefreshPolicy === "1" || - forceRefreshPolicy === "true" || - resolvePolicy === "1" || - resolvePolicy === "true", + forceRefreshPolicy === "1" || forceRefreshPolicy === "true", requirePolicyId: false, }, ); @@ -109,7 +118,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -137,7 +146,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -158,12 +167,12 @@ export class FanavaranController { @ApiOperation({ summary: "Submit Fanavaran damage-case request", description: - "Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.", + "Submits the GEN.12 dmg-cases request. If base claim (claimId) is missing, soft-ensures GEN.03 base claim first, then submits damage. Skips when dmgCaseId already exists.", }) @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -191,7 +200,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -217,7 +226,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -243,7 +252,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -269,7 +278,7 @@ export class FanavaranController { @ApiParam({ name: "client", description: "Fanavaran tenant key", - enum: ["parsian", "tejaratno"], + enum: FANAVARAN_CLIENT_SWAGGER_ENUM, }) @ApiParam({ name: "claimCaseId", @@ -291,7 +300,7 @@ export class FanavaranController { private parseClientParam(client: string) { if (!isFanavaranClientKey(client)) { throw new BadRequestException( - `Invalid Fanavaran client "${client}". Expected one of: parsian, tejaratno`, + `Invalid Fanavaran client "${client}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`, ); } return normalizeFanavaranClientKey(client); diff --git a/src/fanavaran/schema/fanavaran-audit-log.schema.ts b/src/fanavaran/schema/fanavaran-audit-log.schema.ts index 84c8fa1..c718881 100644 --- a/src/fanavaran/schema/fanavaran-audit-log.schema.ts +++ b/src/fanavaran/schema/fanavaran-audit-log.schema.ts @@ -62,12 +62,33 @@ export class FanavaranAuditLog { @Prop({ type: String, required: false }) requestUrl?: string; + @Prop({ type: String, required: false }) + requestMethod?: string; + @Prop({ type: Number, required: false }) httpStatus?: number; + /** Sanitized outbound headers (secrets masked). */ + @Prop({ type: Object, required: false }) + requestHeaders?: Record; + + /** Outbound JSON/body (truncated; secrets masked). */ + @Prop({ type: Object, required: false }) + requestBody?: unknown; + + /** Compact structured facts (ids, flags) — kept for filtering/dashboards. */ @Prop({ type: Object, required: false }) requestMeta?: Record; + /** Sanitized inbound response headers. */ + @Prop({ type: Object, required: false }) + responseHeaders?: Record; + + /** Inbound response body (truncated). */ + @Prop({ type: Object, required: false }) + responseBody?: unknown; + + /** Compact structured facts from the response. */ @Prop({ type: Object, required: false }) responseMeta?: Record; @@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema = SchemaFactory.createForClass(FanavaranAuditLog); FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 }); +FanavaranAuditLogSchema.index({ claimCaseId: 1, createdAt: 1 }); diff --git a/src/fanavaran/schema/fanavaran-auth-token.schema.ts b/src/fanavaran/schema/fanavaran-auth-token.schema.ts new file mode 100644 index 0000000..0ef93ba --- /dev/null +++ b/src/fanavaran/schema/fanavaran-auth-token.schema.ts @@ -0,0 +1,24 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; +import type { FanavaranClientKey } from "src/core/config/fanavaran-client.config"; + +/** + * Shared Fanavaran authenticationToken per tenant. + * Survives process restarts / multi-instance so we do not Login on every request. + */ +@Schema({ collection: "fanavaranAuthTokens", timestamps: true }) +export class FanavaranAuthToken { + @Prop({ type: String, required: true, unique: true, index: true }) + clientKey: FanavaranClientKey; + + @Prop({ type: String, required: true }) + authenticationToken: string; + + /** When this token should be refreshed (Asia/Tehran midnight). */ + @Prop({ type: Date, required: true, index: true }) + expiresAt: Date; +} + +export type FanavaranAuthTokenDocument = HydratedDocument; +export const FanavaranAuthTokenSchema = + SchemaFactory.createForClass(FanavaranAuthToken);