import { Injectable, Logger } from '@nestjs/common'; import { MessageAudience } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { envOr } from '../../common/env'; import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers'; /** * SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway * pulled out into drivers (`./drivers`) selected by `SMS_PROVIDER`: * * `relay` (default, D26) — the owner's createsms gateway * * `trez` — the reference's Trez panel * * `none` — send nothing * * Reference behaviour kept for every driver: * * the message is trim()ed before sending (`Sms.php:77`) * * EVERY message is persisted, via `Sms::set()` (`:80`) * * the response body is returned and the callers ignore it * * 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. */ export type { SmsSendResult } from './drivers'; 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; } /** The selected `SMS_PROVIDER`, lower-cased. */ export function smsProvider(): string { return envOr('SMS_PROVIDER', DEFAULT_SMS_PROVIDER).toLowerCase(); } /** The selected driver, or null for `none` and unknown names. */ export function smsDriver(): SmsDriver | null { return SMS_DRIVERS[smsProvider()] ?? null; } /** * 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 driver = smsDriver(); return driver !== null && driver.missingConfig().length === 0; } /** * Called from main.ts before the app listens. Reports the SMS configuration * and NEVER throws. * * D20 reversed the earlier rule (D14) that a missing credential should abort * the boot in production. The server now always starts: a missing or unknown * provider is a warning, `send()` declines silently and logs, and no message — * including the welcome SMS — can stop an operation. The trade-off is * deliberate and accepted: with SMS unconfigured, customers cannot receive a * login code, which is why `OTP_MASTER_CODE` exists for the delivery. */ export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): void { const provider = smsProvider(); if (provider === 'none') { logger.warn('SMS_PROVIDER=none — no SMS will be sent.'); return; } const driver = smsDriver(); if (!driver) { const supported = [...Object.keys(SMS_DRIVERS), 'none'].map((n) => `"${n}"`).join(', '); logger.warn( `Unknown SMS_PROVIDER "${provider}" (supported: ${supported}). No SMS will be sent.`, ); return; } const missing = driver.missingConfig(); if (missing.length === 0) { logger.log(`SMS provider: ${driver.name}`); return; } logger.warn( `SMS provider "${driver.name}" is selected but these are not set: ${missing.join(', ')}. ` + 'Messages are logged, not sent. Customers cannot receive a login code.', ); } @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(); console.log("mobile ==>" ,mobile) const result = await this.deliver(mobile, text); if (persist) { // `Sms::set()` records every message, delivered or not. await this.record(mobile, text, audience); } return result; } private async deliver(mobile: string, text: string): Promise { const driver = smsDriver(); if (!driver || driver.missingConfig().length > 0) { this.logger.warn(`[dev] SMS not configured; not sending to ${mobile}`); return { ok: false, error: 'sms-not-configured' }; } let result: SmsSendResult; try { result = await driver.deliver(mobile, text); } catch (e) { result = { ok: false, error: e instanceof Error ? e.message : String(e) }; } if (!result.ok) { // Logged, not thrown — see the class comment. const detail = result.response ? ` ${result.response}` : ''; this.logger.error(`SMS to ${mobile} via ${driver.name} failed: ${result.error}${detail}`); } return result; } /** 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)}`); } } }