201 lines
6.9 KiB
TypeScript
201 lines
6.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|||
|
|
import { MessageAudience } from '@prisma/client';
|
||
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||
|
|
*
|
||
|
|
* Reference behaviour (`Sms::Send`, `Sms.php:57-82`):
|
||
|
|
* * POST, form-urlencoded, to the Trez panel, 20s timeout
|
||
|
|
* * fields: PhoneNumber (sender line), Username, Password, Mobile, Message
|
||
|
|
* * the message is trim()ed before sending
|
||
|
|
* * EVERY message is persisted, via `Sms::set()` (`:80`)
|
||
|
|
* * the response body is returned and the callers ignore it
|
||
|
|
*
|
||
|
|
* Two things differ, both deliberate:
|
||
|
|
* 1. Credentials come from the environment. The reference hardcodes live
|
||
|
|
* production credentials in the source (SEC-12); they are not copied here.
|
||
|
|
* 2. The OTP message body is NOT persisted in readable form — see
|
||
|
|
* CustomerAuthService.deliver() and SEC-11.
|
||
|
|
*
|
||
|
|
* Failure handling matches the reference: `Sms::Send` is called inline and its
|
||
|
|
* result is discarded, so a failed send never aborted the order, the document
|
||
|
|
* approval or the customer creation that triggered it. `send()` therefore
|
||
|
|
* never throws — it logs and reports failure in its return value.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** Reference endpoint (`Sms.php:69`). Overridable for staging. */
|
||
|
|
const DEFAULT_TREZ_URL = 'https://smspanel.trez.ir/SendMessageWithCode.ashx';
|
||
|
|
|
||
|
|
/** `Http::timeout(20)` in the reference (`Sms.php:72`). */
|
||
|
|
const REQUEST_TIMEOUT_MS = 20_000;
|
||
|
|
|
||
|
|
export interface SmsSendResult {
|
||
|
|
ok: boolean;
|
||
|
|
/** Provider response body, when one was received. */
|
||
|
|
response?: string;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface SmsSendOptions {
|
||
|
|
/**
|
||
|
|
* Persist the message body. Default true, matching `Sms::set()`.
|
||
|
|
* OTP delivery passes false and writes its own redacted row (SEC-11).
|
||
|
|
*/
|
||
|
|
persist?: boolean;
|
||
|
|
audience?: MessageAudience;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||
|
|
export function smsConfig() {
|
||
|
|
return {
|
||
|
|
provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(),
|
||
|
|
url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL,
|
||
|
|
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||
|
|
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||
|
|
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* True when a real gateway is configured. When false the service stays in
|
||
|
|
* development mode: nothing is sent, and OTP codes are logged locally.
|
||
|
|
*/
|
||
|
|
export function smsConfigured(): boolean {
|
||
|
|
const c = smsConfig();
|
||
|
|
if (c.provider === 'none') return false;
|
||
|
|
return Boolean(c.username && c.password && c.senderLine);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Called from main.ts before the app listens. In production a missing key is
|
||
|
|
* fatal: without it no customer can log in, and a silent no-op gives no clue
|
||
|
|
* why. Outside production it is only a warning, so local development works
|
||
|
|
* with no credentials at all.
|
||
|
|
*/
|
||
|
|
export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void {
|
||
|
|
const c = smsConfig();
|
||
|
|
|
||
|
|
if (c.provider === 'none') {
|
||
|
|
const message = 'SMS_PROVIDER=none — no SMS will be sent.';
|
||
|
|
if (process.env.NODE_ENV === 'production') {
|
||
|
|
throw new Error(
|
||
|
|
`${message} Refusing to start in production: customers could not log in. ` +
|
||
|
|
'Set SMS_PROVIDER=trez and the SMS_TREZ_* variables.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
logger.warn(message);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (c.provider !== 'trez') {
|
||
|
|
throw new Error(
|
||
|
|
`Unknown SMS_PROVIDER "${c.provider}". Supported values: "trez", "none".`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const missing = (
|
||
|
|
[
|
||
|
|
['SMS_TREZ_USERNAME', c.username],
|
||
|
|
['SMS_TREZ_PASSWORD', c.password],
|
||
|
|
['SMS_TREZ_SENDER_LINE', c.senderLine],
|
||
|
|
] as const
|
||
|
|
)
|
||
|
|
.filter(([, v]) => !v)
|
||
|
|
.map(([name]) => name);
|
||
|
|
|
||
|
|
if (missing.length === 0) return;
|
||
|
|
|
||
|
|
const message = `SMS provider "trez" is selected but these are not set: ${missing.join(', ')}.`;
|
||
|
|
if (process.env.NODE_ENV === 'production') {
|
||
|
|
throw new Error(
|
||
|
|
`${message} Refusing to start in production: no customer could receive a login code. ` +
|
||
|
|
'Set them, or set SMS_PROVIDER=none to acknowledge that SMS is disabled.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
logger.warn(`${message} Running in development mode — messages are logged, not sent.`);
|
||
|
|
}
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class SmsService {
|
||
|
|
private readonly logger = new Logger(SmsService.name);
|
||
|
|
|
||
|
|
constructor(private readonly prisma: PrismaService) {}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sends one message. Never throws — a failed SMS must not roll back the
|
||
|
|
* business operation that triggered it, exactly as in the reference.
|
||
|
|
*/
|
||
|
|
async send(mobile: string, body: string, options: SmsSendOptions = {}): Promise<SmsSendResult> {
|
||
|
|
const { persist = true, audience = MessageAudience.SINGLE_NUMBER } = options;
|
||
|
|
// The reference trims before sending (`Sms.php:77`).
|
||
|
|
const text = body.trim();
|
||
|
|
|
||
|
|
let result: SmsSendResult;
|
||
|
|
|
||
|
|
if (!smsConfigured()) {
|
||
|
|
this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`);
|
||
|
|
result = { ok: false, error: 'sms-not-configured' };
|
||
|
|
} else {
|
||
|
|
result = await this.deliverViaTrez(mobile, text);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (persist) {
|
||
|
|
// `Sms::set()` records every message, delivered or not.
|
||
|
|
await this.record(mobile, text, audience);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** POST to the Trez panel, shaped exactly as the reference's form post. */
|
||
|
|
private async deliverViaTrez(mobile: string, text: string): Promise<SmsSendResult> {
|
||
|
|
const c = smsConfig();
|
||
|
|
const controller = new AbortController();
|
||
|
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch(c.url, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
|
|
body: new URLSearchParams({
|
||
|
|
PhoneNumber: c.senderLine,
|
||
|
|
Username: c.username,
|
||
|
|
Password: c.password,
|
||
|
|
Mobile: mobile,
|
||
|
|
Message: text,
|
||
|
|
}).toString(),
|
||
|
|
signal: controller.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
const responseBody = await response.text();
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
// Logged, not thrown — see the class comment.
|
||
|
|
this.logger.error(`SMS to ${mobile} failed: HTTP ${response.status} ${responseBody}`);
|
||
|
|
return { ok: false, response: responseBody, error: `http-${response.status}` };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { ok: true, response: responseBody };
|
||
|
|
} catch (e) {
|
||
|
|
const error = e instanceof Error ? e.message : String(e);
|
||
|
|
this.logger.error(`SMS to ${mobile} failed: ${error}`);
|
||
|
|
return { ok: false, error };
|
||
|
|
} finally {
|
||
|
|
clearTimeout(timer);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The `messages` row the reference writes for every send (`Sms.php:120-129`). */
|
||
|
|
private async record(mobile: string, body: string, audience: MessageAudience) {
|
||
|
|
try {
|
||
|
|
await this.prisma.smsMessage.create({
|
||
|
|
data: { audience, recipients: mobile, body, sentCount: 1 },
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
// Never let the audit write break the caller either.
|
||
|
|
this.logger.error(`Could not record the SMS to ${mobile}: ${String(e)}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|