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 { 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 { 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)}`); } } }