/** * SMS notification service using Twilio. * * Reads TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER from the * app_config table first, then falls back to env vars. If any of those are * missing, sendSms is a no-op that returns { success: false } and logs a * warning. This keeps the app running in environments where Twilio has not * been provisioned yet. */ import twilio, { type Twilio } from "twilio"; import { childLogger } from "../_core/logger.js"; import { getConfigValue } from "../db.js"; const log = childLogger("sms"); let cachedClient: Twilio | null = null; let cachedClientSid: string | null = null; async function resolveTwilioConfig(): Promise<{ sid: string | null; token: string | null; phone: string | null; }> { const [sid, token, phone] = await Promise.all([ getConfigValue("TWILIO_ACCOUNT_SID"), getConfigValue("TWILIO_AUTH_TOKEN"), getConfigValue("TWILIO_PHONE_NUMBER"), ]); return { sid: sid ?? process.env.TWILIO_ACCOUNT_SID ?? null, token: token ?? process.env.TWILIO_AUTH_TOKEN ?? null, phone: phone ?? process.env.TWILIO_PHONE_NUMBER ?? null, }; } export async function isSmsConfigured(): Promise { const cfg = await resolveTwilioConfig(); return Boolean(cfg.sid && cfg.token && cfg.phone); } async function getClient(): Promise<{ client: Twilio; from: string } | null> { const cfg = await resolveTwilioConfig(); if (!cfg.sid || !cfg.token || !cfg.phone) return null; if (cachedClient && cachedClientSid === cfg.sid) { return { client: cachedClient, from: cfg.phone }; } try { cachedClient = twilio(cfg.sid, cfg.token); cachedClientSid = cfg.sid; return { client: cachedClient, from: cfg.phone }; } catch (err) { log.error({ err }, "failed to initialise Twilio client"); return null; } } function normalizePhone(input: string): string { const trimmed = input.trim(); if (trimmed.startsWith("+")) return trimmed; const digits = trimmed.replace(/[^0-9]/g, ""); return `+${digits}`; } export async function sendSms( to: string, body: string ): Promise<{ success: boolean; messageId?: string; error?: string }> { const ctx = await getClient(); if (!ctx) { log.warn({ to: to.slice(0, 4) + "***" }, "Twilio not configured — SMS skipped"); return { success: false, error: "Twilio non configuré" }; } const normalized = normalizePhone(to); try { const msg = await ctx.client.messages.create({ to: normalized, from: ctx.from, body, }); log.info({ messageId: msg.sid, to: normalized.slice(0, 4) + "***" }, "SMS sent"); return { success: true, messageId: msg.sid }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); log.error({ err, to: normalized.slice(0, 4) + "***" }, "SMS send failed"); return { success: false, error: errorMessage }; } }