forked from Yara724/api
Merge pull request 'sms logging in mongo part was added' (#255) from s.hajizadeh/yara724api:main into main
Reviewed-on: Yara724/api#255
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
readOtpExpireMinutesFromEnv,
|
readOtpExpireMinutesFromEnv,
|
||||||
} from "src/helpers/user-otp-expiry";
|
} from "src/helpers/user-otp-expiry";
|
||||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
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 { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||||
import { HashService } from "src/utils/hash/hash.service";
|
import { HashService } from "src/utils/hash/hash.service";
|
||||||
@@ -38,6 +39,7 @@ export class UserAuthService {
|
|||||||
private readonly hashService: HashService,
|
private readonly hashService: HashService,
|
||||||
private readonly otpCreator: OtpGeneratorService,
|
private readonly otpCreator: OtpGeneratorService,
|
||||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||||
|
private readonly smsSendLogService: SmsSendLogService,
|
||||||
private readonly userLinkAccessService: UserLinkAccessService,
|
private readonly userLinkAccessService: UserLinkAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -175,6 +177,10 @@ export class UserAuthService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
|
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
|
||||||
);
|
);
|
||||||
|
await this.smsSendLogService.recordSkippedOtp({
|
||||||
|
receptor: mobile,
|
||||||
|
otp: FAKE_OTP_CODE,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/sms-orchestration/entities/schema/sms-send-log.schema.ts
Normal file
119
src/sms-orchestration/entities/schema/sms-send-log.schema.ts
Normal 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 });
|
||||||
2
src/sms-orchestration/provider/kavenegar-sender.ts
Normal file
2
src/sms-orchestration/provider/kavenegar-sender.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/** Kavenegar shortcode used on plain `send` (and recorded on SMS logs). */
|
||||||
|
export const KAVENEGAR_SMS_SENDER = "10008663";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SmsProviderException,
|
SmsProviderException,
|
||||||
SmsTransportException,
|
SmsTransportException,
|
||||||
} from "./sms-provider.exception";
|
} from "./sms-provider.exception";
|
||||||
|
import { KAVENEGAR_SMS_SENDER } from "./kavenegar-sender";
|
||||||
import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
|
import { SendMessage, VerifyLookUpMessage } from "./sms-gateway.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -22,7 +23,7 @@ export class KavenegarSmsGateway {
|
|||||||
async sendMessage(data: SendMessage) {
|
async sendMessage(data: SendMessage) {
|
||||||
try {
|
try {
|
||||||
const body = await this.sender.send({
|
const body = await this.sender.send({
|
||||||
sender: "10008663",
|
sender: KAVENEGAR_SMS_SENDER,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ export class KavenegarSmsGateway {
|
|||||||
|
|
||||||
async verifyLookUp(data: VerifyLookUpMessage) {
|
async verifyLookUp(data: VerifyLookUpMessage) {
|
||||||
try {
|
try {
|
||||||
const body = await this.sender.verifyLookup(data);
|
const { isOtp: _isOtp, ...providerPayload } = data;
|
||||||
|
const body = await this.sender.verifyLookup(providerPayload);
|
||||||
|
|
||||||
if (!isKavenegarSuccess(body)) {
|
if (!isKavenegarSuccess(body)) {
|
||||||
const normalized = normalizeKavenegarBody(body);
|
const normalized = normalizeKavenegarBody(body);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
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 { 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 { KavenegarService } from "./kavenegar.service";
|
||||||
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
|
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
|
||||||
@@ -16,13 +22,17 @@ import { SmsGatewayService } from "./sms-gateway.service";
|
|||||||
useFactory: createHttpModuleOptions,
|
useFactory: createHttpModuleOptions,
|
||||||
}),
|
}),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: SmsSendLog.name, schema: SmsSendLogSchema },
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
KavenegarService,
|
KavenegarService,
|
||||||
KavenegarSmsGateway,
|
KavenegarSmsGateway,
|
||||||
ParsianSmsGateway,
|
ParsianSmsGateway,
|
||||||
|
SmsSendLogService,
|
||||||
SmsGatewayService,
|
SmsGatewayService,
|
||||||
],
|
],
|
||||||
exports: [SmsGatewayService],
|
exports: [SmsGatewayService, SmsSendLogService],
|
||||||
})
|
})
|
||||||
export class SmsGatewayModule {}
|
export class SmsGatewayModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { SmsSendLogService } from "../entities/db-service/sms-send-log.service";
|
||||||
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
|
import { KavenegarSmsGateway } from "./kavenegar-sms.gateway";
|
||||||
import { ParsianSmsGateway } from "./parsian-sms.gateway";
|
import { ParsianSmsGateway } from "./parsian-sms.gateway";
|
||||||
import { getSmsProvider } from "./sms-provider.config";
|
import { getSmsProvider } from "./sms-provider.config";
|
||||||
@@ -13,6 +14,7 @@ export class SmsGatewayService implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly kavenegar: KavenegarSmsGateway,
|
private readonly kavenegar: KavenegarSmsGateway,
|
||||||
private readonly parsian: ParsianSmsGateway,
|
private readonly parsian: ParsianSmsGateway,
|
||||||
|
private readonly smsSendLogService: SmsSendLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
@@ -20,11 +22,43 @@ export class SmsGatewayService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(data: SendMessage) {
|
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) {
|
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 {
|
private activeGateway(): KavenegarSmsGateway | ParsianSmsGateway {
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ export interface VerifyLookUpMessage {
|
|||||||
token3?: string;
|
token3?: string;
|
||||||
token10?: string;
|
token10?: string;
|
||||||
receptor: string;
|
receptor: string;
|
||||||
|
/** When true, SMS log records this as an OTP (plain-text token preserved). */
|
||||||
|
isOtp?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
|||||||
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
|
MongooseModule.forFeature([{ name: SmsText.name, schema: SmsTextSchema }]),
|
||||||
],
|
],
|
||||||
providers: [SmsTextDbService, SmsOrchestrationService, OtpGeneratorService],
|
providers: [SmsTextDbService, SmsOrchestrationService, OtpGeneratorService],
|
||||||
exports: [SmsOrchestrationService, OtpGeneratorService],
|
exports: [
|
||||||
|
SmsOrchestrationService,
|
||||||
|
OtpGeneratorService,
|
||||||
|
SmsGatewayModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class SmsOrchestrationModule {}
|
export class SmsOrchestrationModule {}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type TemplateArgs = {
|
|||||||
token2?: string;
|
token2?: string;
|
||||||
token3?: string;
|
token3?: string;
|
||||||
token10?: string;
|
token10?: string;
|
||||||
|
isOtp?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -80,6 +81,7 @@ export class SmsOrchestrationService implements OnModuleInit {
|
|||||||
template: template || process.env.AUTH_SMS_TEMPLATE || "",
|
template: template || process.env.AUTH_SMS_TEMPLATE || "",
|
||||||
receptor: mobile,
|
receptor: mobile,
|
||||||
token: otp,
|
token: otp,
|
||||||
|
isOtp: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user