fanavaran duplication request problems fixed.

This commit is contained in:
2026-08-02 17:00:19 +03:30
parent c2f5c576fa
commit b345818d43
9 changed files with 884 additions and 123 deletions

View File

@@ -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 policyInquiryUrl = `https://apimanager.iraneit.com/BimeApiManager/api/BimeApi/v2.0/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=${nationalCodeOfInsurer}`;
const startedAt = Date.now(); const startedAt = Date.now();
const requestMeta = {
corpId: config.corpId,
contractId: config.contractId,
location: config.location,
nationalCode: this.fanavaranAuditService.maskNationalCode(
nationalCodeOfInsurer,
),
InsuranceLineId: 5,
};
if (auditSession) { if (auditSession) {
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.POLICY_INQUIRY, step: FanavaranAuditStep.POLICY_INQUIRY,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: policyInquiryUrl, requestUrl: policyInquiryUrl,
requestMeta: { requestMethod: "GET",
corpId: config.corpId, requestMeta,
contractId: config.contractId,
location: config.location,
nationalCode: this.fanavaranAuditService.maskNationalCode(
nationalCodeOfInsurer,
),
},
}); });
} }
@@ -4313,6 +4317,10 @@ export class ClaimRequestManagementService {
clientKey, clientKey,
auditSession, auditSession,
); );
const requestHeaders = {
...headers,
"Content-Type": "application/json",
};
this.logger.log( this.logger.log(
`${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`, `${logPrefix} Calling policy inquiry API for nationalCode: ${nationalCodeOfInsurer}`,
@@ -4320,10 +4328,7 @@ export class ClaimRequestManagementService {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.get(policyInquiryUrl, { this.httpService.get(policyInquiryUrl, {
headers: { headers: requestHeaders,
...headers,
"Content-Type": "application/json",
},
timeout: 15000, timeout: 15000,
}), }),
); );
@@ -4351,17 +4356,27 @@ export class ClaimRequestManagementService {
this.fanavaranAuthService.clearBackoff(clientKey); this.fanavaranAuthService.clearBackoff(clientKey);
if (auditSession) { if (auditSession) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.POLICY_INQUIRY, step: FanavaranAuditStep.POLICY_INQUIRY,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: policyInquiryUrl, requestUrl: policyInquiryUrl,
requestMethod: "GET",
httpStatus: response.status, httpStatus: response.status,
requestHeaders: exchange.requestHeaders,
requestMeta,
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { responseMeta: {
policyId: selectedPolicy.policyId, policyId: selectedPolicy.policyId,
policyEndDate: selectedPolicy.endDate, policyEndDate: selectedPolicy.endDate,
policyEndDateGregorian: selectedPolicy.endDateGregorian, policyEndDateGregorian: selectedPolicy.endDateGregorian,
policyCount, policyCount,
fromCache: false,
}, },
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -4372,12 +4387,19 @@ export class ClaimRequestManagementService {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "unknown policy inquiry error"; error instanceof Error ? error.message : "unknown policy inquiry error";
if (auditSession) { if (auditSession) {
const exchange =
this.fanavaranAuditService.captureAxiosExchange(error);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.POLICY_INQUIRY, step: FanavaranAuditStep.POLICY_INQUIRY,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: policyInquiryUrl, 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, errorMessage,
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
@@ -4624,7 +4646,9 @@ export class ClaimRequestManagementService {
} }
const cachedPolicyId = 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({ const payload = await this.buildFanavaranSubmitPayload({
accidentReason: selectedAccidentReason, accidentReason: selectedAccidentReason,
@@ -4674,6 +4698,7 @@ export class ClaimRequestManagementService {
if (cachedPolicyId != null && !forceRefreshPolicy) { if (cachedPolicyId != null && !forceRefreshPolicy) {
debug.steps.policyIdFromCache = true; debug.steps.policyIdFromCache = true;
debug.values.policyId = Number(cachedPolicyId); debug.values.policyId = Number(cachedPolicyId);
// Silent reuse — no Fanavaran HTTP and no audit noise on warm preview.
return Number(cachedPolicyId); return Number(cachedPolicyId);
} }
@@ -5018,7 +5043,37 @@ export class ClaimRequestManagementService {
} }
const profile = getFanavaranClientProfile(clientKey); 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) { if (!claimCase?.claimId) {
return { return {
clientKey, clientKey,
@@ -5219,13 +5274,14 @@ export class ClaimRequestManagementService {
step: FanavaranAuditStep.SUBMIT_ATTACHMENT, step: FanavaranAuditStep.SUBMIT_ATTACHMENT,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: url, requestUrl: url,
requestMethod: "POST",
requestBody: content,
requestMeta: { requestMeta: {
claimId: claimCase.claimId, claimId: claimCase.claimId,
claimNo: claimCase.claimNo, claimNo: claimCase.claimNo,
dmgCaseId: claimCase.dmgCaseId, dmgCaseId: claimCase.dmgCaseId,
fileName, fileName,
source: file.source, source: file.source,
content,
}, },
}); });
@@ -5234,18 +5290,25 @@ export class ClaimRequestManagementService {
content, content,
[{ path: filePath, fileName }], [{ path: filePath, fileName }],
clientKey, clientKey,
auditSession,
); );
this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`); this.logger.log(`${logPrefix} Fanavaran response: ${JSON.stringify(response.data)}`);
const fileId = response.data?.Id; const fileId = response.data?.Id;
const exchange =
this.fanavaranAuditService.captureAxiosExchange(response);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_ATTACHMENT, step: FanavaranAuditStep.SUBMIT_ATTACHMENT,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: url, requestUrl: url,
requestMethod: "POST",
httpStatus: response.status, httpStatus: response.status,
requestBody: content,
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { claimId: claimCase.claimId, fileId, fileName }, responseMeta: { claimId: claimCase.claimId, fileId, fileName },
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -5693,6 +5756,11 @@ export class ClaimRequestManagementService {
bodyOverride?: Record<string, unknown>, bodyOverride?: Record<string, unknown>,
): Promise<any> { ): Promise<any> {
try { try {
await this.ensureFanavaranDamageCase(
claimCaseId,
clientKey,
FanavaranAuditSource.SUBMIT,
);
const claimCase = await this.claimCaseDbService.findById(claimCaseId); const claimCase = await this.claimCaseDbService.findById(claimCaseId);
if (!claimCase) throw new NotFoundException("Claim case not found"); if (!claimCase) throw new NotFoundException("Claim case not found");
let payload: Record<string, unknown>; let payload: Record<string, unknown>;
@@ -5728,12 +5796,30 @@ export class ClaimRequestManagementService {
payload: Record<string, unknown>, payload: Record<string, unknown>,
auditSource: FanavaranAuditSource, auditSource: FanavaranAuditSource,
): Promise<any> { ): Promise<any> {
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) { if (!claimCase?.claimId) {
throw new BadRequestException( throw new BadRequestException(
"Fanavaran claimId is required before submitting expertise", "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 url = `${this.FANAVARAN_SUBMIT_URL}/${claimCase.claimId}/expertise`;
const startedAt = Date.now(); const startedAt = Date.now();
const auditSession: FanavaranAuditSession = { const auditSession: FanavaranAuditSession = {
@@ -5748,19 +5834,32 @@ export class ClaimRequestManagementService {
step: FanavaranAuditStep.SUBMIT_EXPERTISE, step: FanavaranAuditStep.SUBMIT_EXPERTISE,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: url, requestUrl: url,
requestMeta: { claimId: claimCase.claimId, payload }, requestMethod: "POST",
requestBody: payload,
requestMeta: { claimId: claimCase.claimId },
}); });
try { try {
const response = await this.postFanavaranJson(url, payload, clientKey); const response = await this.postFanavaranJson(
url,
payload,
clientKey,
auditSession,
);
const expertiseId = response.data?.Id; const expertiseId = response.data?.Id;
const exchange =
this.fanavaranAuditService.captureAxiosExchange(response);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_EXPERTISE, step: FanavaranAuditStep.SUBMIT_EXPERTISE,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: url, requestUrl: url,
requestMethod: "POST",
httpStatus: response.status, httpStatus: response.status,
requestBody: payload,
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { claimId: claimCase.claimId, expertiseId }, responseMeta: { claimId: claimCase.claimId, expertiseId },
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -5781,12 +5880,17 @@ export class ClaimRequestManagementService {
return response.data; return response.data;
} catch (error) { } catch (error) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(error);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_EXPERTISE, step: FanavaranAuditStep.SUBMIT_EXPERTISE,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: url, 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), errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
@@ -5952,13 +6056,104 @@ export class ClaimRequestManagementService {
return null; 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( private async postFanavaranJson(
url: string, url: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
auditSession?: FanavaranAuditSession,
) { ) {
this.fanavaranAuthService.assertNotInBackoff(clientKey); this.fanavaranAuthService.assertNotInBackoff(clientKey);
const headers = await this.getFanavaranAuthHeaders(clientKey); const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession);
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
@@ -5982,9 +6177,10 @@ export class ClaimRequestManagementService {
content: Record<string, unknown>, content: Record<string, unknown>,
files: Array<{ path: string; fileName: string }>, files: Array<{ path: string; fileName: string }>,
clientKey: FanavaranClientKey, clientKey: FanavaranClientKey,
auditSession?: FanavaranAuditSession,
) { ) {
this.fanavaranAuthService.assertNotInBackoff(clientKey); this.fanavaranAuthService.assertNotInBackoff(clientKey);
const headers = await this.getFanavaranAuthHeaders(clientKey); const headers = await this.getFanavaranAuthHeaders(clientKey, auditSession);
const form = new FormData(); const form = new FormData();
form.append("Param", JSON.stringify(content), { form.append("Param", JSON.stringify(content), {
@@ -6043,13 +6239,27 @@ export class ClaimRequestManagementService {
bodyOverride?: Record<string, unknown>, bodyOverride?: Record<string, unknown>,
): Promise<any> { ): Promise<any> {
const profile = getFanavaranClientProfile(clientKey); const profile = getFanavaranClientProfile(clientKey);
const claimCase = await this.claimCaseDbService.findById(claimCaseId); let claimCase = await this.claimCaseDbService.findById(claimCaseId);
if (!claimCase) { if (!claimCase) {
throw new NotFoundException("Claim case not found"); throw new NotFoundException("Claim case not found");
} }
if (!claimCase.claimId) { if (!claimCase.claimId) {
throw new BadRequestException( await this.ensureFanavaranBaseClaim(claimCaseId, clientKey, auditSource);
"Fanavaran claimId is required before submitting damage case", 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) { if (!claimCase.blameRequestId) {
@@ -6092,19 +6302,32 @@ export class ClaimRequestManagementService {
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: url, requestUrl: url,
requestMeta: { claimId: claimCase.claimId, payload }, requestMethod: "POST",
requestBody: payload,
requestMeta: { claimId: claimCase.claimId },
}); });
try { try {
const response = await this.postFanavaranJson(url, payload, clientKey); const response = await this.postFanavaranJson(
url,
payload,
clientKey,
auditSession,
);
const dmgCaseId = response.data?.Id; const dmgCaseId = response.data?.Id;
const exchange =
this.fanavaranAuditService.captureAxiosExchange(response);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: url, requestUrl: url,
requestMethod: "POST",
httpStatus: response.status, httpStatus: response.status,
requestBody: payload,
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { dmgCaseId, claimId: claimCase.claimId }, responseMeta: { dmgCaseId, claimId: claimCase.claimId },
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
}); });
@@ -6134,12 +6357,17 @@ export class ClaimRequestManagementService {
this.logger.error( this.logger.error(
`[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`, `[executeFanavaranDamageCaseSubmit] FAILED claimCaseId=${claimCaseId} claimId=${claimCase.claimId} payload.DriverId=${payload.DriverId ?? "NULL"} error: ${errDetail}`,
); );
const exchange = this.fanavaranAuditService.captureAxiosExchange(error);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE, step: FanavaranAuditStep.SUBMIT_DAMAGE_CASE,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: url, 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), errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
@@ -6830,6 +7058,19 @@ export class ClaimRequestManagementService {
`${logPrefix} Starting submission for claimCaseId: ${claimCaseId}`, `${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 = { const auditSession: FanavaranAuditSession = {
trackingCode: this.fanavaranAuditService.generateTrackingCode(), trackingCode: this.fanavaranAuditService.generateTrackingCode(),
clientKey, clientKey,
@@ -6868,11 +7109,18 @@ export class ClaimRequestManagementService {
auditSession, auditSession,
); );
const requestHeaders = {
...headers,
"Content-Type": "application/json",
};
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM, step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: this.FANAVARAN_SUBMIT_URL, requestUrl: this.FANAVARAN_SUBMIT_URL,
requestMethod: "POST",
requestHeaders,
requestBody: fanavaranData,
requestMeta: { policyId: fanavaranData?.PolicyId ?? null }, requestMeta: { policyId: fanavaranData?.PolicyId ?? null },
}); });
const startedAt = Date.now(); const startedAt = Date.now();
@@ -6880,10 +7128,7 @@ export class ClaimRequestManagementService {
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, { this.httpService.post(this.FANAVARAN_SUBMIT_URL, fanavaranData, {
headers: { headers: requestHeaders,
...headers,
"Content-Type": "application/json",
},
}), }),
); );
@@ -6895,12 +7140,21 @@ export class ClaimRequestManagementService {
this.fanavaranAuthService.clearBackoff(clientKey); this.fanavaranAuthService.clearBackoff(clientKey);
const exchange = this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM, step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.FANAVARAN_SUBMIT_URL, requestUrl: this.FANAVARAN_SUBMIT_URL,
requestMethod: "POST",
httpStatus: response.status, httpStatus: response.status,
requestHeaders: exchange.requestHeaders,
requestBody: fanavaranData,
responseHeaders: exchange.responseHeaders,
responseBody: exchange.responseBody,
responseMeta: { responseMeta: {
claimId: response.data?.Id, claimId: response.data?.Id,
claimNo: response.data?.ClaimNo, claimNo: response.data?.ClaimNo,
@@ -6946,12 +7200,21 @@ export class ClaimRequestManagementService {
return response.data; return response.data;
} catch (error) { } catch (error) {
this.fanavaranAuthService.registerFailure(clientKey, error); this.fanavaranAuthService.registerFailure(clientKey, error);
const exchange = this.fanavaranAuditService.captureAxiosExchange(
error,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.SUBMIT_CLAIM, step: FanavaranAuditStep.SUBMIT_CLAIM,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: this.FANAVARAN_SUBMIT_URL, 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), errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,

View File

@@ -1,20 +1,26 @@
export type FanavaranClientKey = "parsian" | "tejaratno"; export type FanavaranClientKey = "parsian" | "tejaratno" | "moallem";
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [ export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
"parsian", "parsian",
"tejaratno", "tejaratno",
"moallem",
] as const; ] 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( export function isFanavaranClientKey(
value: string, value: string,
): value is FanavaranClientKey { ): value is FanavaranClientKey {
const normalized = value?.trim().toLowerCase(); 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 { export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
const normalized = value?.trim().toLowerCase(); const normalized = value?.trim().toLowerCase();
if (normalized === "parsian" || normalized === "tejaratno") { if (isFanavaranClientKey(normalized)) {
return normalized; return normalized;
} }
throw new Error( throw new Error(
@@ -56,6 +62,29 @@ export interface FanavaranClientProfile {
defaults: FanavaranPayloadDefaults; 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< const FANAVARAN_CLIENT_PROFILES: Record<
FanavaranClientKey, FanavaranClientKey,
FanavaranClientProfile FanavaranClientProfile
@@ -72,20 +101,9 @@ const FANAVARAN_CLIENT_PROFILES: Record<
location: "100", location: "100",
}, },
defaults: { defaults: {
AccidentCityId: 701, ...SHARED_FANAVARAN_DEFAULTS,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 4543092, ClaimExpertId: 4543092,
ExpertiseClaimExpertId: 4543092, ExpertiseClaimExpertId: 4543092,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 23, ClaimFileTypeId: 23,
}, },
}, },
@@ -101,29 +119,35 @@ const FANAVARAN_CLIENT_PROFILES: Record<
location: "210050", location: "210050",
}, },
defaults: { defaults: {
AccidentCityId: 701, ...SHARED_FANAVARAN_DEFAULTS,
AccidentReportTypeId: 155,
AccidentVehicleUsedId: 1,
ClaimExpertId: 154, ClaimExpertId: 154,
ExpertiseClaimExpertId: 29, ExpertiseClaimExpertId: 29,
CompensationReferenceId: 167,
CulpritLicenceTypeId: 2,
CulpritTypeId: 337,
DmgCaseTypeId: 175,
DmgHistoryStatus: 5214,
PlaqueKindId: 8,
PlaqueSampleId: 10,
DriverIsOwner: 0,
FaultPercent: 100,
ClaimFileTypeId: 70, 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. */ /** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
export function resolveFanavaranClientKey(): FanavaranClientKey { export function resolveFanavaranClientKey(): FanavaranClientKey {
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase(); const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
if (explicit === "parsian" || explicit === "tejaratno") { if (explicit && isFanavaranClientKey(explicit)) {
return explicit; return explicit;
} }

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose"; import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError } from "axios"; import { isAxiosError, type AxiosResponse } from "axios";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { Model, Types } from "mongoose"; import { Model, Types } from "mongoose";
import { import {
@@ -16,18 +16,54 @@ export interface RecordFanavaranAuditStepInput {
step: FanavaranAuditStep; step: FanavaranAuditStep;
status: FanavaranAuditStatus; status: FanavaranAuditStatus;
requestUrl?: string; requestUrl?: string;
requestMethod?: string;
httpStatus?: number; httpStatus?: number;
requestHeaders?: Record<string, unknown>;
requestBody?: unknown;
requestMeta?: Record<string, unknown>; requestMeta?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
responseMeta?: Record<string, unknown>; responseMeta?: Record<string, unknown>;
errorMessage?: string; errorMessage?: string;
errorDetails?: Record<string, unknown>; errorDetails?: Record<string, unknown>;
durationMs?: number; durationMs?: number;
} }
export interface FanavaranHttpExchange {
httpStatus?: number;
requestHeaders?: Record<string, unknown>;
responseHeaders?: Record<string, unknown>;
responseBody?: unknown;
}
@Injectable() @Injectable()
export class FanavaranAuditService { export class FanavaranAuditService {
private readonly logger = new Logger(FanavaranAuditService.name); 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( constructor(
@InjectModel(FanavaranAuditLog.name) @InjectModel(FanavaranAuditLog.name)
private readonly auditModel: Model<FanavaranAuditLogDocument>, private readonly auditModel: Model<FanavaranAuditLogDocument>,
@@ -47,6 +83,138 @@ export class FanavaranAuditService {
return `${trimmed.slice(0, 3)}****${trimmed.slice(-2)}`; 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<string, unknown> | null,
): Record<string, unknown> | undefined {
if (!headers || typeof headers !== "object") {
return undefined;
}
const out: Record<string, unknown> = {};
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<string, unknown>,
): FanavaranHttpExchange {
const toPlainHeaders = (
headers?: Record<string, unknown> | null,
): Record<string, unknown> | undefined => {
if (!headers || typeof headers !== "object") return undefined;
const out: Record<string, unknown> = {};
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<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
source.response?.headers as Record<string, unknown> | 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<string, unknown> | undefined),
),
responseHeaders: toPlainHeaders(
response.headers as Record<string, unknown> | undefined,
),
responseBody: response.data,
};
}
return {
requestHeaders: toPlainHeaders(overrideRequestHeaders),
};
}
sanitizeErrorDetails(error: unknown): Record<string, unknown> { sanitizeErrorDetails(error: unknown): Record<string, unknown> {
if (isAxiosError(error)) { if (isAxiosError(error)) {
const data = error.response?.data; const data = error.response?.data;
@@ -54,12 +222,13 @@ export class FanavaranAuditService {
type: "axios", type: "axios",
status: error.response?.status, status: error.response?.status,
statusText: error.response?.statusText, statusText: error.response?.statusText,
data: data: this.sanitizeBody(
typeof data === "object" && data !== null typeof data === "object" && data !== null
? data ? data
: typeof data === "string" : typeof data === "string"
? data.slice(0, 2000) ? data.slice(0, 2000)
: data, : data,
),
}; };
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -112,8 +281,13 @@ export class FanavaranAuditService {
? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) } ? { claimRequestId: new Types.ObjectId(input.session.claimRequestId) }
: {}), : {}),
requestUrl: input.requestUrl, requestUrl: input.requestUrl,
requestMethod: input.requestMethod,
httpStatus: input.httpStatus, httpStatus: input.httpStatus,
requestHeaders: this.sanitizeHeaders(input.requestHeaders),
requestBody: this.sanitizeBody(input.requestBody),
requestMeta: input.requestMeta, requestMeta: input.requestMeta,
responseHeaders: this.sanitizeHeaders(input.responseHeaders),
responseBody: this.sanitizeBody(input.responseBody),
responseMeta: input.responseMeta, responseMeta: input.responseMeta,
errorMessage: input.errorMessage, errorMessage: input.errorMessage,
errorDetails: input.errorDetails, errorDetails: input.errorDetails,
@@ -134,4 +308,31 @@ export class FanavaranAuditService {
.lean() .lean()
.exec(); .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<string, unknown> = {};
for (const [key, child] of Object.entries(
value as Record<string, unknown>,
)) {
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;
}
} }

View File

@@ -1,6 +1,43 @@
import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranAuthService } from "./fanavaran-auth.service";
describe("FanavaranAuthService", () => { 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", () => { it("detects Fanavaran transient try-later messages", () => {
expect( expect(
FanavaranAuthService.isTransientTryLaterError( FanavaranAuthService.isTransientTryLaterError(
@@ -24,22 +61,11 @@ describe("FanavaranAuthService", () => {
e instanceof Error ? e.message : String(e), e instanceof Error ? e.message : String(e),
sanitizeErrorDetails: () => ({}), sanitizeErrorDetails: () => ({}),
formatErrorWithTrackingCode: (m: string) => m, formatErrorWithTrackingCode: (m: string) => m,
captureAxiosExchange: () => ({}),
}; };
const authTokenModel = createAuthTokenModel();
let loginCalls = 0; 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"); const { of, delay } = await import("rxjs");
http.post.mockImplementation((url: string) => { http.post.mockImplementation((url: string) => {
if (url.includes("GetAppToken")) { if (url.includes("GetAppToken")) {
@@ -57,7 +83,11 @@ describe("FanavaranAuthService", () => {
}).pipe(delay(20)); }).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([ const [a, b, c] = await Promise.all([
service.getAuthenticationToken("parsian"), service.getAuthenticationToken("parsian"),
@@ -75,28 +105,74 @@ describe("FanavaranAuthService", () => {
expect(loginCalls).toBe(1); 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", () => { 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( service.registerFailure(
"parsian", "parsian",
"کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.", "کد پیگیری خطا: 1\r\n.لطفا پس از چند لحظه مجدد تلاش فرمایید.",
); );
expect(service.isInBackoff("parsian")).toBe(true); expect(service.isInBackoff("parsian")).toBe(true);
expect(() => service.assertNotInBackoff("parsian")).toThrow( expect(() => service.assertNotInBackoff("parsian")).toThrow(/backoff/i);
/backoff/i,
);
}); });
it("computes next Asia/Tehran midnight expiry after now", () => { 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 now = Date.parse("2026-08-02T10:00:00.000Z");
const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now); const expiry = FanavaranAuthService.getNextMidnightExpiryMs(now);
expect(expiry).toBeGreaterThan(now); expect(expiry).toBeGreaterThan(now);
// Must land within ~14h (before next Tehran midnight)
expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000); expect(expiry - now).toBeLessThanOrEqual(24 * 60 * 60 * 1000);
expect(expiry - now).toBeGreaterThan(0); 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 justAfterMidnight = Date.parse("2026-08-01T20:30:01.000Z");
const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight); const next = FanavaranAuthService.getNextMidnightExpiryMs(justAfterMidnight);
expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000); expect(next - justAfterMidnight).toBeGreaterThan(23 * 60 * 60 * 1000);

View File

@@ -6,7 +6,9 @@ import {
Logger, Logger,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { Model } from "mongoose";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { import {
getFanavaranClientProfile, getFanavaranClientProfile,
@@ -19,6 +21,10 @@ import {
FanavaranAuditStatus, FanavaranAuditStatus,
FanavaranAuditStep, FanavaranAuditStep,
} from "./schema/fanavaran-audit-log.schema"; } from "./schema/fanavaran-audit-log.schema";
import {
FanavaranAuthToken,
FanavaranAuthTokenDocument,
} from "./schema/fanavaran-auth-token.schema";
interface CachedFanavaranAuth { interface CachedFanavaranAuth {
authenticationToken: string; authenticationToken: string;
@@ -63,6 +69,8 @@ export class FanavaranAuthService {
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly fanavaranAuditService: FanavaranAuditService, private readonly fanavaranAuditService: FanavaranAuditService,
@InjectModel(FanavaranAuthToken.name)
private readonly authTokenModel: Model<FanavaranAuthTokenDocument>,
) {} ) {}
/** True when Fanavaran asked us to wait (Persian “try again later” / tracking-code 500). */ /** 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 { invalidateToken(clientKey: FanavaranClientKey): void {
this.tokenCache.delete(clientKey); 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 { assertNotInBackoff(clientKey: FanavaranClientKey): void {
@@ -183,9 +197,13 @@ export class FanavaranAuthService {
this.assertNotInBackoff(clientKey); this.assertNotInBackoff(clientKey);
if (!options?.forceRefresh) { if (!options?.forceRefresh) {
const cached = this.tokenCache.get(clientKey); const memoryHit = this.readMemoryCache(clientKey);
if (cached && cached.expiresAt > Date.now()) { if (memoryHit) {
return cached.authenticationToken; return memoryHit;
}
const persisted = await this.readPersistedCache(clientKey);
if (persisted) {
return persisted;
} }
} else { } else {
this.invalidateToken(clientKey); this.invalidateToken(clientKey);
@@ -243,10 +261,7 @@ export class FanavaranAuthService {
); );
const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs(); const expiresAt = FanavaranAuthService.getNextMidnightExpiryMs();
this.tokenCache.set(clientKey, { await this.persistToken(clientKey, authenticationToken, expiresAt);
authenticationToken,
expiresAt,
});
this.clearBackoff(clientKey); this.clearBackoff(clientKey);
this.logger.log( this.logger.log(
`[${clientKey}] Cached Fanavaran authenticationToken until ${new Date( `[${clientKey}] Cached Fanavaran authenticationToken until ${new Date(
@@ -256,6 +271,76 @@ export class FanavaranAuthService {
return authenticationToken; 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<string | null> {
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<void> {
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() { private emptyBodyTransformRequest() {
return [ return [
(_data: unknown, headers?: Record<string, unknown>) => { (_data: unknown, headers?: Record<string, unknown>) => {
@@ -273,12 +358,20 @@ export class FanavaranAuthService {
auditSession?: FanavaranAuditSession, auditSession?: FanavaranAuditSession,
): Promise<string> { ): Promise<string> {
const startedAt = Date.now(); const startedAt = Date.now();
const requestHeaders = {
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
};
if (auditSession) { if (auditSession) {
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN, step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: this.getAppTokenUrl, requestUrl: this.getAppTokenUrl,
requestMethod: "POST",
requestHeaders,
requestBody: "",
requestMeta: { appName: config.appName, cached: false }, requestMeta: { appName: config.appName, cached: false },
}); });
} }
@@ -286,11 +379,7 @@ export class FanavaranAuthService {
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post(this.getAppTokenUrl, "", { this.httpService.post(this.getAppTokenUrl, "", {
headers: { headers: requestHeaders,
appname: config.appName,
secret: config.secret,
"Content-Length": "0",
},
transformRequest: this.emptyBodyTransformRequest(), transformRequest: this.emptyBodyTransformRequest(),
}), }),
); );
@@ -306,13 +395,23 @@ export class FanavaranAuthService {
} }
if (auditSession) { if (auditSession) {
const exchange =
this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN, step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.getAppTokenUrl, requestUrl: this.getAppTokenUrl,
requestMethod: "POST",
httpStatus: response.status, 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, durationMs: Date.now() - startedAt,
}); });
} }
@@ -320,12 +419,21 @@ export class FanavaranAuthService {
return appToken; return appToken;
} catch (error) { } catch (error) {
if (auditSession) { if (auditSession) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(
error,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.GET_APP_TOKEN, step: FanavaranAuditStep.GET_APP_TOKEN,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: this.getAppTokenUrl, 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), errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
@@ -341,12 +449,21 @@ export class FanavaranAuthService {
auditSession?: FanavaranAuditSession, auditSession?: FanavaranAuditSession,
): Promise<string> { ): Promise<string> {
const startedAt = Date.now(); const startedAt = Date.now();
const requestHeaders = {
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
};
if (auditSession) { if (auditSession) {
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.LOGIN, step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.STARTED, status: FanavaranAuditStatus.STARTED,
requestUrl: this.loginUrl, requestUrl: this.loginUrl,
requestMethod: "POST",
requestHeaders,
requestBody: "",
requestMeta: { userName: config.username, cached: false }, requestMeta: { userName: config.username, cached: false },
}); });
} }
@@ -354,12 +471,7 @@ export class FanavaranAuthService {
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.httpService.post(this.loginUrl, "", { this.httpService.post(this.loginUrl, "", {
headers: { headers: requestHeaders,
appToken,
userName: config.username,
password: config.password,
"Content-Length": "0",
},
transformRequest: this.emptyBodyTransformRequest(), transformRequest: this.emptyBodyTransformRequest(),
}), }),
); );
@@ -380,13 +492,26 @@ export class FanavaranAuthService {
} }
if (auditSession) { if (auditSession) {
const exchange =
this.fanavaranAuditService.captureAxiosExchange(
response,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.LOGIN, step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.SUCCESS, status: FanavaranAuditStatus.SUCCESS,
requestUrl: this.loginUrl, requestUrl: this.loginUrl,
requestMethod: "POST",
httpStatus: response.status, 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, durationMs: Date.now() - startedAt,
}); });
} }
@@ -394,12 +519,21 @@ export class FanavaranAuthService {
return authenticationToken; return authenticationToken;
} catch (error) { } catch (error) {
if (auditSession) { if (auditSession) {
const exchange = this.fanavaranAuditService.captureAxiosExchange(
error,
requestHeaders,
);
await this.fanavaranAuditService.recordStep({ await this.fanavaranAuditService.recordStep({
session: auditSession, session: auditSession,
step: FanavaranAuditStep.LOGIN, step: FanavaranAuditStep.LOGIN,
status: FanavaranAuditStatus.FAILURE, status: FanavaranAuditStatus.FAILURE,
requestUrl: this.loginUrl, 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), errorMessage: this.fanavaranAuditService.extractErrorMessage(error),
errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error), errorDetails: this.fanavaranAuditService.sanitizeErrorDetails(error),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,

View File

@@ -1,10 +1,15 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config"; import { ConfigModule, ConfigService } from "@nestjs/config";
import { MongooseModule } from "@nestjs/mongoose";
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
import { FanavaranAuditModule } from "./fanavaran-audit.module"; import { FanavaranAuditModule } from "./fanavaran-audit.module";
import { FanavaranAuthService } from "./fanavaran-auth.service"; import { FanavaranAuthService } from "./fanavaran-auth.service";
import { FanavaranLookupService } from "./fanavaran-lookup.service"; import { FanavaranLookupService } from "./fanavaran-lookup.service";
import {
FanavaranAuthToken,
FanavaranAuthTokenSchema,
} from "./schema/fanavaran-auth-token.schema";
@Module({ @Module({
imports: [ imports: [
@@ -13,6 +18,9 @@ import { FanavaranLookupService } from "./fanavaran-lookup.service";
inject: [ConfigService], inject: [ConfigService],
useFactory: createHttpModuleOptions, useFactory: createHttpModuleOptions,
}), }),
MongooseModule.forFeature([
{ name: FanavaranAuthToken.name, schema: FanavaranAuthTokenSchema },
]),
FanavaranAuditModule, FanavaranAuditModule,
], ],
providers: [FanavaranAuthService, FanavaranLookupService], providers: [FanavaranAuthService, FanavaranLookupService],

View File

@@ -18,6 +18,8 @@ import {
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard"; import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service"; import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
import { import {
FANAVARAN_CLIENT_KEYS,
FANAVARAN_CLIENT_SWAGGER_ENUM,
isFanavaranClientKey, isFanavaranClientKey,
listFanavaranClientProfiles, listFanavaranClientProfiles,
normalizeFanavaranClientKey, normalizeFanavaranClientKey,
@@ -37,7 +39,7 @@ export class FanavaranController {
@ApiOperation({ @ApiOperation({
summary: "List supported Fanavaran insurance clients", summary: "List supported Fanavaran insurance clients",
description: 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() { listClients() {
const activeClient = resolveFanavaranClientKey(); const activeClient = resolveFanavaranClientKey();
@@ -60,7 +62,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -75,26 +77,33 @@ export class FanavaranController {
name: "forceRefreshPolicy", name: "forceRefreshPolicy",
required: false, required: false,
description: 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( async preview(
@Param("client") client: string, @Param("client") client: string,
@Param("claimCaseId") claimCaseId: string, @Param("claimCaseId") claimCaseId: string,
@Query("debug") debug?: string, @Query("debug") debug?: string,
@Query("forceRefreshPolicy") forceRefreshPolicy?: string, @Query("forceRefreshPolicy") forceRefreshPolicy?: string,
@Query("resolvePolicy") resolvePolicy?: string, @Query("resolvePolicy") _resolvePolicy?: string,
) { ) {
const clientKey = this.parseClientParam(client); 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( return await this.claimRequestManagementService.previewFanavaranSubmitV2(
claimCaseId, claimCaseId,
clientKey, clientKey,
{ {
debug: debug === "1" || debug === "true", debug: debug === "1" || debug === "true",
forceRefreshPolicy: forceRefreshPolicy:
forceRefreshPolicy === "1" || forceRefreshPolicy === "1" || forceRefreshPolicy === "true",
forceRefreshPolicy === "true" ||
resolvePolicy === "1" ||
resolvePolicy === "true",
requirePolicyId: false, requirePolicyId: false,
}, },
); );
@@ -109,7 +118,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -137,7 +146,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -158,12 +167,12 @@ export class FanavaranController {
@ApiOperation({ @ApiOperation({
summary: "Submit Fanavaran damage-case request", summary: "Submit Fanavaran damage-case request",
description: 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({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -191,7 +200,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -217,7 +226,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -243,7 +252,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -269,7 +278,7 @@ export class FanavaranController {
@ApiParam({ @ApiParam({
name: "client", name: "client",
description: "Fanavaran tenant key", description: "Fanavaran tenant key",
enum: ["parsian", "tejaratno"], enum: FANAVARAN_CLIENT_SWAGGER_ENUM,
}) })
@ApiParam({ @ApiParam({
name: "claimCaseId", name: "claimCaseId",
@@ -291,7 +300,7 @@ export class FanavaranController {
private parseClientParam(client: string) { private parseClientParam(client: string) {
if (!isFanavaranClientKey(client)) { if (!isFanavaranClientKey(client)) {
throw new BadRequestException( 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); return normalizeFanavaranClientKey(client);

View File

@@ -62,12 +62,33 @@ export class FanavaranAuditLog {
@Prop({ type: String, required: false }) @Prop({ type: String, required: false })
requestUrl?: string; requestUrl?: string;
@Prop({ type: String, required: false })
requestMethod?: string;
@Prop({ type: Number, required: false }) @Prop({ type: Number, required: false })
httpStatus?: number; httpStatus?: number;
/** Sanitized outbound headers (secrets masked). */
@Prop({ type: Object, required: false })
requestHeaders?: Record<string, unknown>;
/** 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 }) @Prop({ type: Object, required: false })
requestMeta?: Record<string, unknown>; requestMeta?: Record<string, unknown>;
/** Sanitized inbound response headers. */
@Prop({ type: Object, required: false })
responseHeaders?: Record<string, unknown>;
/** Inbound response body (truncated). */
@Prop({ type: Object, required: false })
responseBody?: unknown;
/** Compact structured facts from the response. */
@Prop({ type: Object, required: false }) @Prop({ type: Object, required: false })
responseMeta?: Record<string, unknown>; responseMeta?: Record<string, unknown>;
@@ -86,3 +107,4 @@ export const FanavaranAuditLogSchema =
SchemaFactory.createForClass(FanavaranAuditLog); SchemaFactory.createForClass(FanavaranAuditLog);
FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 }); FanavaranAuditLogSchema.index({ trackingCode: 1, createdAt: 1 });
FanavaranAuditLogSchema.index({ claimCaseId: 1, createdAt: 1 });

View File

@@ -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<FanavaranAuthToken>;
export const FanavaranAuthTokenSchema =
SchemaFactory.createForClass(FanavaranAuthToken);