import { Injectable, Logger } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; import { Model } from "mongoose"; import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config"; import { buildParsianTemplateMessage } from "../../provider/parsian-template-messages"; import { describeSmsError, SmsProviderException, SmsTransportException, } from "../../provider/sms-provider.exception"; import { getSmsProvider } from "../../provider/sms-provider.config"; import type { SendMessage, VerifyLookUpMessage, } from "../../provider/sms-gateway.types"; import { KAVENEGAR_SMS_SENDER } from "../../provider/kavenegar-sender"; import { SmsSendLog, SmsSendLogDocument, SmsSendLogKind, SmsSendLogOperation, SmsSendLogProvider, SmsSendLogStatus, } from "../schema/sms-send-log.schema"; export type RecordSmsSendLogInput = { receptor: string; provider: SmsSendLogProvider; sender?: string; operation: SmsSendLogOperation; status: SmsSendLogStatus; kind: SmsSendLogKind; messageText?: string; template?: string; tokens?: { token?: string; token2?: string; token3?: string; token10?: string; }; isOtp?: boolean; tenant?: string; providerResponse?: unknown; errorMessage?: string; errorDetails?: Record; durationMs?: number; }; @Injectable() export class SmsSendLogService { private readonly logger = new Logger(SmsSendLogService.name); constructor( @InjectModel(SmsSendLog.name) private readonly smsSendLogModel: Model, ) {} /** Persist one SMS attempt; never throws (mirrors Fanavaran audit). */ async record(input: RecordSmsSendLogInput): Promise { try { await this.smsSendLogModel.create({ receptor: input.receptor, provider: input.provider, sender: input.sender, operation: input.operation, status: input.status, kind: input.kind, messageText: input.messageText, template: input.template, tokens: input.tokens, isOtp: input.isOtp, tenant: input.tenant ?? resolveFanavaranClientKey(), providerResponse: input.providerResponse, errorMessage: input.errorMessage, errorDetails: input.errorDetails, durationMs: input.durationMs, }); } catch (error) { this.logger.error( `Failed to persist SMS send log receptor=${input.receptor} status=${input.status}`, error, ); } } activeGatewayProvider(): SmsSendLogProvider { return getSmsProvider() === "parsian" ? SmsSendLogProvider.PARSIAN : SmsSendLogProvider.KAVENEGAR; } senderForProvider(provider: SmsSendLogProvider): string | undefined { if (provider === SmsSendLogProvider.KAVENEGAR) { return KAVENEGAR_SMS_SENDER; } return undefined; } isAuthOtp(data: Pick): boolean { if (data.isOtp === true) { return true; } const auth = process.env.AUTH_SMS_TEMPLATE?.trim(); return Boolean(auth && data.template && data.template === auth); } resolveMessageTextForLookup(data: VerifyLookUpMessage): string { return buildParsianTemplateMessage(data); } async recordGatewaySend(params: { data: SendMessage; startedAt: number; result?: unknown; error?: unknown; }): Promise { const provider = this.activeGatewayProvider(); const durationMs = Date.now() - params.startedAt; if (params.error == null) { await this.record({ receptor: params.data.receptor, provider, sender: this.senderForProvider(provider), operation: SmsSendLogOperation.SEND, status: SmsSendLogStatus.SUCCESS, kind: SmsSendLogKind.PLAIN, messageText: params.data.message, providerResponse: params.result, durationMs, }); return; } await this.record({ receptor: params.data.receptor, provider, sender: this.senderForProvider(provider), operation: SmsSendLogOperation.SEND, status: SmsSendLogStatus.FAILURE, kind: SmsSendLogKind.PLAIN, messageText: params.data.message, ...this.errorFields(params.error), durationMs, }); } async recordGatewayVerifyLookup(params: { data: VerifyLookUpMessage; startedAt: number; result?: unknown; error?: unknown; }): Promise { const provider = this.activeGatewayProvider(); const durationMs = Date.now() - params.startedAt; const isOtp = this.isAuthOtp(params.data); const tokens = { token: params.data.token, ...(params.data.token2 != null ? { token2: params.data.token2 } : {}), ...(params.data.token3 != null ? { token3: params.data.token3 } : {}), ...(params.data.token10 != null ? { token10: params.data.token10 } : {}), }; const messageText = this.resolveMessageTextForLookup(params.data); const kind = isOtp ? SmsSendLogKind.OTP : SmsSendLogKind.TEMPLATE; if (params.error == null) { await this.record({ receptor: params.data.receptor, provider, sender: this.senderForProvider(provider), operation: SmsSendLogOperation.VERIFY_LOOKUP, status: SmsSendLogStatus.SUCCESS, kind, messageText, template: params.data.template, tokens, isOtp, providerResponse: params.result, durationMs, }); return; } await this.record({ receptor: params.data.receptor, provider, sender: this.senderForProvider(provider), operation: SmsSendLogOperation.VERIFY_LOOKUP, status: SmsSendLogStatus.FAILURE, kind, messageText, template: params.data.template, tokens, isOtp, ...this.errorFields(params.error), durationMs, }); } /** FAKE_OTP / local path — no external gateway call. */ async recordSkippedOtp(params: { receptor: string; otp: string; reason?: string; }): Promise { const authMessage = process.env.AUTH_SMS_MESSAGE?.trim() ?? "کاربر محترم رمز یکبار مصرف شما:\nCode: {token}"; const messageText = authMessage.split("{token}").join(params.otp); await this.record({ receptor: params.receptor, provider: SmsSendLogProvider.TEJARATNO, operation: SmsSendLogOperation.SKIPPED, status: SmsSendLogStatus.SKIPPED, kind: SmsSendLogKind.SKIPPED, messageText, template: process.env.AUTH_SMS_TEMPLATE?.trim() || undefined, tokens: { token: params.otp }, isOtp: true, errorMessage: params.reason ?? "FAKE_OTP enabled — SMS provider not called", }); } private errorFields(error: unknown): { errorMessage: string; errorDetails?: Record; providerResponse?: unknown; } { if (error instanceof SmsProviderException) { return { errorMessage: describeSmsError(error), providerResponse: error.meta.providerBody, errorDetails: { operation: error.operation, normalized: error.meta.normalized, }, }; } if (error instanceof SmsTransportException) { return { errorMessage: describeSmsError(error), errorDetails: { operation: error.operation, cause: error.causeUnknown instanceof Error ? { name: error.causeUnknown.name, message: error.causeUnknown.message, } : error.causeUnknown, }, }; } return { errorMessage: describeSmsError(error), }; } }