forked from Yara724/api
260 lines
7.5 KiB
TypeScript
260 lines
7.5 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|