queue-med/server/services/sms.ts
Hermes bd580b849e feat: admin settings page - Stripe/Twilio/WhatsApp config UI
- Add AdminSettings page with 4 tabs: Integrations, WhatsApp, Notifications, General
- Add tRPC admin endpoints: listConfig, setConfig, deleteConfig, testStripeConnection, testSmsConnection
- Add clinicSettings.toggleSms endpoint for per-clinic SMS toggle
- Add app_config table schema + DB helpers (listAllConfig, setConfigValue, deleteConfigValue)
- Stripe and SMS services now read config from DB first, then env vars fallback
- Add Settings nav item in sidebar (admin only)
- Add /admin/settings route in App.tsx
2026-04-25 23:55:43 +00:00

90 lines
2.8 KiB
TypeScript

/**
* 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<boolean> {
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 };
}
}