diff --git a/src/auth/auth-services/user.auth.service.ts b/src/auth/auth-services/user.auth.service.ts index 0805592..19b22f1 100644 --- a/src/auth/auth-services/user.auth.service.ts +++ b/src/auth/auth-services/user.auth.service.ts @@ -19,6 +19,7 @@ import { readOtpExpireMinutesFromEnv, } from "src/helpers/user-otp-expiry"; import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service"; +import { SmsSendLogService } from "src/sms-orchestration/entities/db-service/sms-send-log.service"; import { UserDbService } from "src/users/entities/db-service/user.db.service"; import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service"; import { HashService } from "src/utils/hash/hash.service"; @@ -38,6 +39,7 @@ export class UserAuthService { private readonly hashService: HashService, private readonly otpCreator: OtpGeneratorService, private readonly smsOrchestrationService: SmsOrchestrationService, + private readonly smsSendLogService: SmsSendLogService, private readonly userLinkAccessService: UserLinkAccessService, ) {} @@ -175,6 +177,10 @@ export class UserAuthService { this.logger.log( `FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`, ); + await this.smsSendLogService.recordSkippedOtp({ + receptor: mobile, + otp: FAKE_OTP_CODE, + }); return; } const ok = await this.smsOrchestrationService.sendAuthOtp( diff --git a/src/sms-orchestration/entities/db-service/sms-send-log.service.spec.ts b/src/sms-orchestration/entities/db-service/sms-send-log.service.spec.ts new file mode 100644 index 0000000..3764625 --- /dev/null +++ b/src/sms-orchestration/entities/db-service/sms-send-log.service.spec.ts @@ -0,0 +1,95 @@ +import { SmsSendLogService } from "./sms-send-log.service"; +import { + SmsSendLogKind, + SmsSendLogOperation, + SmsSendLogProvider, + SmsSendLogStatus, +} from "../schema/sms-send-log.schema"; + +describe("SmsSendLogService", () => { + const createMock = jest.fn(); + let service: SmsSendLogService; + + beforeEach(() => { + createMock.mockReset(); + createMock.mockResolvedValue({}); + service = new SmsSendLogService({ + create: createMock, + } as never); + delete process.env.SMS; + delete process.env.SMS_PROVIDER; + process.env.AUTH_SMS_TEMPLATE = "auth-otp"; + process.env.AUTH_SMS_MESSAGE = + "کاربر محترم رمز یکبار مصرف شما:\nCode: {token}"; + }); + + it("records successful verifyLookup OTP with plain-text code and kavenegar sender", async () => { + await service.recordGatewayVerifyLookup({ + data: { + receptor: "09120000000", + template: "auth-otp", + token: "54321", + isOtp: true, + }, + startedAt: Date.now() - 12, + result: { return: { status: 200 } }, + }); + + expect(createMock).toHaveBeenCalledTimes(1); + const doc = createMock.mock.calls[0][0]; + expect(doc).toMatchObject({ + receptor: "09120000000", + provider: SmsSendLogProvider.KAVENEGAR, + sender: "10008663", + operation: SmsSendLogOperation.VERIFY_LOOKUP, + status: SmsSendLogStatus.SUCCESS, + kind: SmsSendLogKind.OTP, + isOtp: true, + template: "auth-otp", + tokens: { token: "54321" }, + messageText: "کاربر محترم رمز یکبار مصرف شما:\nCode: 54321", + }); + expect(doc.providerResponse).toEqual({ return: { status: 200 } }); + }); + + it("records skipped fake OTP as tejaratno provider with plain-text OTP", async () => { + await service.recordSkippedOtp({ + receptor: "09121111111", + otp: "12345", + }); + + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + receptor: "09121111111", + provider: SmsSendLogProvider.TEJARATNO, + operation: SmsSendLogOperation.SKIPPED, + status: SmsSendLogStatus.SKIPPED, + kind: SmsSendLogKind.SKIPPED, + isOtp: true, + tokens: { token: "12345" }, + messageText: "کاربر محترم رمز یکبار مصرف شما:\nCode: 12345", + }), + ); + }); + + it("records parsian plain send with message text and no sender shortcode", async () => { + process.env.SMS = "parsian"; + + await service.recordGatewaySend({ + data: { receptor: "09123333333", message: "hello party" }, + startedAt: Date.now(), + result: { ok: true }, + }); + + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: SmsSendLogProvider.PARSIAN, + sender: undefined, + operation: SmsSendLogOperation.SEND, + status: SmsSendLogStatus.SUCCESS, + kind: SmsSendLogKind.PLAIN, + messageText: "hello party", + }), + ); + }); +}); diff --git a/src/sms-orchestration/entities/db-service/sms-send-log.service.ts b/src/sms-orchestration/entities/db-service/sms-send-log.service.ts new file mode 100644 index 0000000..129251a --- /dev/null +++ b/src/sms-orchestration/entities/db-service/sms-send-log.service.ts @@ -0,0 +1,259 @@ +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), + }; + } +} diff --git a/src/sms-orchestration/entities/schema/sms-send-log.schema.ts b/src/sms-orchestration/entities/schema/sms-send-log.schema.ts new file mode 100644 index 0000000..b14e52f --- /dev/null +++ b/src/sms-orchestration/entities/schema/sms-send-log.schema.ts @@ -0,0 +1,119 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { HydratedDocument } from "mongoose"; + +/** Who dispatched the SMS: external gateways, or tejaratno when sent/skipped locally. */ +export enum SmsSendLogProvider { + KAVENEGAR = "kavenegar", + PARSIAN = "parsian", + TEJARATNO = "tejaratno", +} + +export enum SmsSendLogOperation { + SEND = "send", + VERIFY_LOOKUP = "verifyLookup", + SKIPPED = "skipped", +} + +export enum SmsSendLogStatus { + SUCCESS = "success", + FAILURE = "failure", + SKIPPED = "skipped", +} + +export enum SmsSendLogKind { + OTP = "otp", + TEMPLATE = "template", + PLAIN = "plain", + SKIPPED = "skipped", +} + +@Schema({ collection: "smsSendLogs", timestamps: true, versionKey: false }) +export class SmsSendLog { + /** Destination mobile number. */ + @Prop({ type: String, required: true, index: true }) + receptor: string; + + /** SMS dispatcher: kavenegar, parsian, or tejaratno (local/skipped). */ + @Prop({ + type: String, + required: true, + enum: SmsSendLogProvider, + index: true, + }) + provider: SmsSendLogProvider; + + /** Originating shortcode/number when the provider exposes one (e.g. Kavenegar). */ + @Prop({ type: String, required: false }) + sender?: string; + + @Prop({ + type: String, + required: true, + enum: SmsSendLogOperation, + index: true, + }) + operation: SmsSendLogOperation; + + @Prop({ + type: String, + required: true, + enum: SmsSendLogStatus, + index: true, + }) + status: SmsSendLogStatus; + + @Prop({ + type: String, + required: true, + enum: SmsSendLogKind, + index: true, + }) + kind: SmsSendLogKind; + + /** Full message body when known (OTP kept as plain text). */ + @Prop({ type: String, required: false }) + messageText?: string; + + /** Kavenegar/Parsian template name for verifyLookup sends. */ + @Prop({ type: String, required: false, index: true }) + template?: string; + + /** + * Template tokens as sent (OTP is `token` for auth — stored plain text on purpose). + */ + @Prop({ type: Object, required: false }) + tokens?: { + token?: string; + token2?: string; + token3?: string; + token10?: string; + }; + + /** True when this send is an auth OTP (or a skipped fake OTP). */ + @Prop({ type: Boolean, required: false, index: true }) + isOtp?: boolean; + + /** Deployment Fanavaran tenant when known (parsian / tejaratno / moallem). */ + @Prop({ type: String, required: false, index: true }) + tenant?: string; + + /** Raw provider API response body (success or rejection). */ + @Prop({ type: Object, required: false }) + providerResponse?: unknown; + + @Prop({ type: String, required: false }) + errorMessage?: string; + + @Prop({ type: Object, required: false }) + errorDetails?: Record; + + @Prop({ type: Number, required: false }) + durationMs?: number; +} + +export type SmsSendLogDocument = HydratedDocument; +export const SmsSendLogSchema = SchemaFactory.createForClass(SmsSendLog); + +SmsSendLogSchema.index({ receptor: 1, createdAt: -1 }); +SmsSendLogSchema.index({ provider: 1, createdAt: -1 }); +SmsSendLogSchema.index({ isOtp: 1, createdAt: -1 }); diff --git a/src/sms-orchestration/provider/kavenegar-sender.ts b/src/sms-orchestration/provider/kavenegar-sender.ts new file mode 100644 index 0000000..e71c096 --- /dev/null +++ b/src/sms-orchestration/provider/kavenegar-sender.ts @@ -0,0 +1,2 @@ +/** Kavenegar shortcode used on plain `send` (and recorded on SMS logs). */ +export const KAVENEGAR_SMS_SENDER = "10008663"; diff --git a/src/sms-orchestration/provider/kavenegar-sms.gateway.ts b/src/sms-orchestration/provider/kavenegar-sms.gateway.ts index 85d0a13..c4c2569 100644 --- a/src/sms-orchestration/provider/kavenegar-sms.gateway.ts +++ b/src/sms-orchestration/provider/kavenegar-sms.gateway.ts @@ -11,6 +11,7 @@ import { SmsProviderException, SmsTransportException, } from "./sms-provider.exception"; +import { KAVENEGAR_SMS_SENDER } from "./kavenegar-sender"; import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types"; @Injectable() @@ -22,7 +23,7 @@ export class KavenegarSmsGateway { async sendMessage(data: SendMessage) { try { const body = await this.sender.send({ - sender: "10008663", + sender: KAVENEGAR_SMS_SENDER, ...data, }); @@ -48,7 +49,8 @@ export class KavenegarSmsGateway { async verifyLookUp(data: VerifyLookUpMessage) { try { - const body = await this.sender.verifyLookup(data); + const { isOtp: _isOtp, ...providerPayload } = data; + const body = await this.sender.verifyLookup(providerPayload); if (!isKavenegarSuccess(body)) { const normalized = normalizeKavenegarBody(body); diff --git a/src/sms-orchestration/provider/sms-gateway.module.ts b/src/sms-orchestration/provider/sms-gateway.module.ts index ce36c71..125a269 100644 --- a/src/sms-orchestration/provider/sms-gateway.module.ts +++ b/src/sms-orchestration/provider/sms-gateway.module.ts @@ -1,7 +1,13 @@ import { HttpModule } from "@nestjs/axios"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; +import { MongooseModule } from "@nestjs/mongoose"; import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; +import { SmsSendLogService } from "../entities/db-service/sms-send-log.service"; +import { + SmsSendLog, + SmsSendLogSchema, +} from "../entities/schema/sms-send-log.schema"; import { KavenegarService } from "./kavenegar.service"; import { KavenegarSmsGateway } from "./kavenegar-sms.gateway"; @@ -16,13 +22,17 @@ import { SmsGatewayService } from "./sms-gateway.service"; useFactory: createHttpModuleOptions, }), ConfigModule, + MongooseModule.forFeature([ + { name: SmsSendLog.name, schema: SmsSendLogSchema }, + ]), ], providers: [ KavenegarService, KavenegarSmsGateway, ParsianSmsGateway, + SmsSendLogService, SmsGatewayService, ], - exports: [SmsGatewayService], + exports: [SmsGatewayService, SmsSendLogService], }) export class SmsGatewayModule {} diff --git a/src/sms-orchestration/provider/sms-gateway.service.ts b/src/sms-orchestration/provider/sms-gateway.service.ts index d4520ca..ace9098 100644 --- a/src/sms-orchestration/provider/sms-gateway.service.ts +++ b/src/sms-orchestration/provider/sms-gateway.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { SmsSendLogService } from "../entities/db-service/sms-send-log.service"; import { KavenegarSmsGateway } from "./kavenegar-sms.gateway"; import { ParsianSmsGateway } from "./parsian-sms.gateway"; import { getSmsProvider } from "./sms-provider.config"; @@ -13,6 +14,7 @@ export class SmsGatewayService implements OnModuleInit { constructor( private readonly kavenegar: KavenegarSmsGateway, private readonly parsian: ParsianSmsGateway, + private readonly smsSendLogService: SmsSendLogService, ) {} onModuleInit() { @@ -20,11 +22,43 @@ export class SmsGatewayService implements OnModuleInit { } async sendMessage(data: SendMessage) { - return this.activeGateway().sendMessage(data); + const startedAt = Date.now(); + try { + const result = await this.activeGateway().sendMessage(data); + await this.smsSendLogService.recordGatewaySend({ + data, + startedAt, + result, + }); + return result; + } catch (error) { + await this.smsSendLogService.recordGatewaySend({ + data, + startedAt, + error, + }); + throw error; + } } async verifyLookUp(data: VerifyLookUpMessage) { - return this.activeGateway().verifyLookUp(data); + const startedAt = Date.now(); + try { + const result = await this.activeGateway().verifyLookUp(data); + await this.smsSendLogService.recordGatewayVerifyLookup({ + data, + startedAt, + result, + }); + return result; + } catch (error) { + await this.smsSendLogService.recordGatewayVerifyLookup({ + data, + startedAt, + error, + }); + throw error; + } } private activeGateway(): KavenegarSmsGateway | ParsianSmsGateway { diff --git a/src/sms-orchestration/provider/sms-gateway.types.ts b/src/sms-orchestration/provider/sms-gateway.types.ts index 95d8713..df4e770 100644 --- a/src/sms-orchestration/provider/sms-gateway.types.ts +++ b/src/sms-orchestration/provider/sms-gateway.types.ts @@ -10,4 +10,6 @@ export interface VerifyLookUpMessage { token3?: string; token10?: string; receptor: string; + /** When true, SMS log records this as an OTP (plain-text token preserved). */ + isOtp?: boolean; } diff --git a/src/sms-orchestration/sms-orchestration.module.ts b/src/sms-orchestration/sms-orchestration.module.ts index 8a02e66..972bc1f 100644 --- a/src/sms-orchestration/sms-orchestration.module.ts +++ b/src/sms-orchestration/sms-orchestration.module.ts @@ -20,6 +20,10 @@ import { createHttpModuleOptions } from "src/core/config/http-proxy.factory"; MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]), ], providers: [SmsTextDbService, SmsOrchestrationService, OtpGeneratorService], - exports: [SmsOrchestrationService, OtpGeneratorService], + exports: [ + SmsOrchestrationService, + OtpGeneratorService, + SmsGatewayModule, + ], }) export class SmsOrchestrationModule {} diff --git a/src/sms-orchestration/sms-orchestration.service.ts b/src/sms-orchestration/sms-orchestration.service.ts index fdd1597..459817a 100644 --- a/src/sms-orchestration/sms-orchestration.service.ts +++ b/src/sms-orchestration/sms-orchestration.service.ts @@ -11,6 +11,7 @@ type TemplateArgs = { token2?: string; token3?: string; token10?: string; + isOtp?: boolean; }; @Injectable() @@ -80,6 +81,7 @@ export class SmsOrchestrationService implements OnModuleInit { template: template || process.env.AUTH_SMS_TEMPLATE || "", receptor: mobile, token: otp, + isOtp: true, }); }