import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { MessageAudience } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { envOr, envOrNull } from '../../common/env'; import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers'; import { isPrivateIpv4, resolveRelayHost } from './drivers/relay.driver'; /** * 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 * * 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()`. * Pass false only when the caller writes its own audit row. */ 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 implements OnApplicationBootstrap { private readonly logger = new Logger(SmsService.name); constructor(private readonly prisma: PrismaService) {} /** * Reports the `host` the relay gateway will receive, so a wrong one shows in * the boot log instead of as silent failures. Never throws and never delays * the boot: the lookup runs in the background. */ onApplicationBootstrap() { if (smsDriver()?.name !== 'relay') return; void resolveRelayHost() .then((host) => { const source = envOrNull('SMS_RELAY_HOST') ? 'SMS_RELAY_HOST' : 'detected'; if (isPrivateIpv4(host)) { this.logger.warn( `SMS relay host is a private address (${host}, ${source}); the gateway may not accept it. ` + "Set SMS_RELAY_HOST to this server's public IP.", ); } else { this.logger.log(`SMS relay host: ${host ?? '(none)'} (${source})`); } }) .catch(() => undefined); } /** * 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(); 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)}`); } } }