- 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
90 lines
2.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|