sms logging in mongo part was added

This commit is contained in:
2026-08-08 12:14:31 +03:30
parent e6038c73c5
commit 07d6cdbe7c
11 changed files with 541 additions and 6 deletions

View File

@@ -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",
}),
);
});
});

View File

@@ -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<string, unknown>;
durationMs?: number;
};
@Injectable()
export class SmsSendLogService {
private readonly logger = new Logger(SmsSendLogService.name);
constructor(
@InjectModel(SmsSendLog.name)
private readonly smsSendLogModel: Model<SmsSendLogDocument>,
) {}
/** Persist one SMS attempt; never throws (mirrors Fanavaran audit). */
async record(input: RecordSmsSendLogInput): Promise<void> {
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<VerifyLookUpMessage, "template" | "isOtp">): 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<void> {
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<void> {
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<void> {
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<string, unknown>;
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),
};
}
}

View File

@@ -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<string, unknown>;
@Prop({ type: Number, required: false })
durationMs?: number;
}
export type SmsSendLogDocument = HydratedDocument<SmsSendLog>;
export const SmsSendLogSchema = SchemaFactory.createForClass(SmsSendLog);
SmsSendLogSchema.index({ receptor: 1, createdAt: -1 });
SmsSendLogSchema.index({ provider: 1, createdAt: -1 });
SmsSendLogSchema.index({ isOtp: 1, createdAt: -1 });