From 9bd134fb5de6789bebd748b28394011e091df6ce Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 18:05:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206=20=E2=80=94=20SMS=20Twilio,?= =?UTF-8?q?=20PDF=20reports,=20QR=20join=20page,=20verifyQr=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + client/src/App.tsx | 3 +- client/src/pages/QrJoin.tsx | 194 +++++++++++++++++++++++ server/routers.ts | 139 ++++++++++++++++ server/schema.ts | 3 + server/services/pdfReport.ts | 298 +++++++++++++++++++++++++++++++++++ server/services/sms.ts | 73 +++++++++ 7 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/QrJoin.tsx create mode 100644 server/services/pdfReport.ts create mode 100644 server/services/sms.ts diff --git a/.env.example b/.env.example index e75c50c..56156dd 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,14 @@ VITE_STRIPE_PRO_PRICE_ID= # Must live on a Docker volume in production so sessions survive restarts. WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions +# ─── Twilio SMS ───────────────────────────────────────────────────────────── +# All Twilio vars are OPTIONAL. If any is missing, SMS sending is disabled +# and the app logs a warning instead of failing. Each clinic also has a +# `smsEnabled` opt-in flag; SMS is never sent without both. +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_PHONE_NUMBER= + # ─── Docker compose only ──────────────────────────────────────────────────── MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password MYSQL_DATABASE=queuemed diff --git a/client/src/App.tsx b/client/src/App.tsx index f018bde..7920749 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import DoctorClinics from "@/pages/DoctorClinics"; import QueueManagement from "@/pages/QueueManagement"; import Analytics from "@/pages/Analytics"; import PatientQueue from "@/pages/PatientQueue"; +import QrJoin from "@/pages/QrJoin"; import DisplayScreen from "@/pages/DisplayScreen"; import SubscriptionPage from "@/pages/SubscriptionPage"; import PrintTicket from "@/pages/PrintTicket"; @@ -58,7 +59,7 @@ export default function App() { - + {/* Authenticated routes (wrapped in Layout) */} diff --git a/client/src/pages/QrJoin.tsx b/client/src/pages/QrJoin.tsx new file mode 100644 index 0000000..6a90186 --- /dev/null +++ b/client/src/pages/QrJoin.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { useParams, useLocation } from "wouter"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; +import { + Stethoscope, QrCode, Smartphone, Loader2, XCircle, User, Phone, MessageCircle, +} from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; + +const VISIT_REASONS = [ + "consultation", "urgence", "certificat_scolaire", "certificat_sportif", + "arret_travail", "administratif", "autre", +] as const; + +export default function QrJoin() { + const { t } = useTranslation(); + const params = useParams<{ clinicId: string; qrToken: string }>(); + const [, navigate] = useLocation(); + const clinicId = parseInt(params.clinicId ?? "0", 10); + const qrToken = params.qrToken ?? ""; + + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [whatsappPhone, setWhatsappPhone] = useState(""); + const [reason, setReason] = useState("consultation"); + const [useSamePhone, setUseSamePhone] = useState(true); + + // Verify QR is valid first + const verifyQuery = trpc.queue.verifyQr.useQuery( + { clinicId, qrToken }, + { enabled: !!qrToken && clinicId > 0, retry: false } + ); + + const joinMutation = trpc.queue.join.useMutation({ + onSuccess: (data) => { + toast.success(t("patient.toastJoined")); + navigate(`/queue/${data.patientToken}`); + }, + onError: (e) => { + toast.error(e.message); + }, + }); + + if (verifyQuery.isLoading) { + return ( +
+ QueueMed + +
+ ); + } + + if (verifyQuery.error || !verifyQuery.data) { + return ( +
+ QueueMed +
+ +

{t("patient.ticketNotFound")}

+

+ {t("patient.ticketNotFoundDesc")} +

+ +
+
+ ); + } + + const { clinicName, isQueueOpen, whatsappConnected } = verifyQuery.data; + + if (!isQueueOpen) { + return ( +
+ QueueMed — {clinicName} +
+ +

File d'attente fermée

+

+ Le cabinet {clinicName} n'accepte plus de patients pour le moment. +

+ +
+
+ ); + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + joinMutation.mutate({ + clinicId, + qrToken, + patientName: name || undefined, + patientPhone: phone || undefined, + whatsappPhone: useSamePhone ? phone : whatsappPhone || undefined, + visitReason: reason as any, + }); + }; + + return ( +
+ QueueMed — {clinicName} + +
+
+ +
+

{clinicName}

+

Rejoignez la salle d'attente virtuelle

+
+ +
+
+ +
+ + setName(e.target.value)} + placeholder="Votre nom" + className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition" + /> +
+
+ +
+ +
+ + setPhone(e.target.value)} + placeholder="+594..." + className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition" + /> +
+
+ + {whatsappConnected && phone && ( + + )} + +
+ + +
+ + +
+
+
+ ); +} diff --git a/server/routers.ts b/server/routers.ts index cd73d76..78cac5c 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -90,6 +90,9 @@ import { isStripeConfigured, } from "./services/stripe.js"; import { checkPlanLimit, getPlanLimitsForUser } from "./services/planLimits.js"; +import { isSmsConfigured, sendSms } from "./services/sms.js"; +import { generateQueueReport } from "./services/pdfReport.js"; +import { buildSmsMessage } from "../shared/smsTemplates.js"; import { childLogger } from "./_core/logger.js"; const authLog = childLogger("auth"); @@ -409,6 +412,28 @@ const clinicRouter = router({ return { success: true }; }), + updateSmsSettings: subscriptionProcedure + .input( + z.object({ + clinicId: z.number().int().positive(), + smsEnabled: z.boolean(), + }) + ) + .mutation(async ({ input, ctx }) => { + const clinic = await getClinicById(input.clinicId); + if (!clinic || clinic.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); + } + if (input.smsEnabled && !isSmsConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Twilio n'est pas configuré sur ce serveur.", + }); + } + await updateClinic(input.clinicId, { smsEnabled: input.smsEnabled }); + return { success: true, smsEnabled: input.smsEnabled }; + }), + regenerateQr: subscriptionProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { @@ -605,6 +630,35 @@ const queueRouter = router({ }), // Patient self-tracking + // Verify QR token validity (for QR join page) + verifyQr: publicProcedure + .input(z.object({ clinicId: z.number().int().positive(), qrToken: z.string().min(8).max(64) })) + .query(async ({ input }) => { + let clinic = await getClinicById(input.clinicId); + if (!clinic || !clinic.isActive) { + throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); + } + // Auto-refresh expired QR token + if ( + clinic.qrTokenExpiresAt && + clinic.qrTokenExpiresAt.getTime() < Date.now() && + clinic.qrRotationMinutes && + clinic.qrRotationMinutes > 0 + ) { + await rotateQrToken(clinic.id); + clinic = (await getClinicById(clinic.id)) ?? clinic; + } + if (clinic.qrToken !== input.qrToken) { + throw new TRPCError({ code: "FORBIDDEN", message: "QR code invalide" }); + } + const waStatus = await getWhatsAppStatus(clinic.id).catch(() => null); + return { + clinicName: clinic.name, + isQueueOpen: clinic.isQueueOpen, + whatsappConnected: waStatus?.connected ?? false, + }; + }), + getByToken: publicProcedure .input(z.object({ patientToken: z.string().min(8).max(64) })) .query(async ({ input }) => { @@ -1244,6 +1298,89 @@ const analyticsRouter = router({ .slice(0, 10)}.csv`; return { csv, filename }; }), + + exportPdf: protectedProcedure + .input( + z.object({ + clinicId: z.number().int().positive(), + days: z.number().int().min(1).max(365).default(30), + }) + ) + .query(async ({ input, ctx }) => { + const clinic = await getClinicById(input.clinicId); + if (!clinic || clinic.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); + } + try { + const buf = await generateQueueReport(input.clinicId, { days: input.days }); + const filename = `queuemed_${clinic.name.replace(/[^a-z0-9]+/gi, "_")}_${new Date() + .toISOString() + .slice(0, 10)}.pdf`; + return { + base64: buf.toString("base64"), + filename, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "PDF generation failed"; + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); + } + }), +}); + +// ─── Notification router (SMS Twilio) ──────────────────────────────────────── +const notificationRouter = router({ + smsStatus: protectedProcedure.query(() => { + return { configured: isSmsConfigured() }; + }), + + sendSms: subscriptionProcedure + .input( + z.object({ + entryId: z.number().int().positive(), + message: z.string().max(500).optional(), + }) + ) + .mutation(async ({ input, ctx }) => { + if (!isSmsConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Twilio n'est pas configuré sur ce serveur.", + }); + } + const entry = await getQueueEntry(input.entryId); + if (!entry) { + throw new TRPCError({ code: "NOT_FOUND", message: "Patient introuvable" }); + } + const clinic = await getClinicById(entry.clinicId); + if (!clinic || clinic.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); + } + if (!clinic.smsEnabled) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Les notifications SMS ne sont pas activées pour ce cabinet.", + }); + } + const phone = entry.patientPhone ?? entry.whatsappPhone ?? null; + if (!phone) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Aucun numéro de téléphone enregistré pour ce patient.", + }); + } + + const message = + input.message ?? + buildSmsMessage("called", { + nom: entry.patientName ?? "", + ticket: entry.ticketNumber, + position: entry.position, + attente: entry.estimatedWaitMinutes ?? 0, + cabinet: clinic.name, + }); + + return sendSms(phone, message); + }), }); // ─── Subscription router ───────────────────────────────────────────────────── @@ -1472,6 +1609,7 @@ const clinicSettingsRouter = router({ whatsappTemplateSoon: clinic.whatsappTemplateSoon ?? null, whatsappTemplateCalled: clinic.whatsappTemplateCalled ?? null, whatsappTemplateWithdrawn: clinic.whatsappTemplateWithdrawn ?? null, + smsEnabled: clinic.smsEnabled ?? false, }; }), @@ -1728,6 +1866,7 @@ export const appRouter = router({ analytics: analyticsRouter, subscription: subscriptionRouter, whatsapp: whatsappRouter, + notification: notificationRouter, clinicSettings: clinicSettingsRouter, history: historyRouter, admin: adminRouter, diff --git a/server/schema.ts b/server/schema.ts index a815da7..26c4379 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -102,6 +102,9 @@ export const clinics = mysqlTable( whatsappTemplateSoon: text("whatsappTemplateSoon"), whatsappTemplateCalled: text("whatsappTemplateCalled"), whatsappTemplateWithdrawn: text("whatsappTemplateWithdrawn"), + // Activation des notifications SMS Twilio (opt-in par cabinet) + // Migration: ALTER TABLE clinics ADD COLUMN smsEnabled TINYINT(1) NOT NULL DEFAULT 0 + smsEnabled: boolean("smsEnabled").default(false).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }, diff --git a/server/services/pdfReport.ts b/server/services/pdfReport.ts new file mode 100644 index 0000000..dcf4d40 --- /dev/null +++ b/server/services/pdfReport.ts @@ -0,0 +1,298 @@ +/** + * PDF report generator (queue analytics) using pdfkit. + * + * generateQueueReport returns a Buffer containing the binary PDF, ready to + * be base64-encoded and shipped to the client. All sections handle empty + * data sets gracefully so a freshly-onboarded clinic still gets a valid PDF. + */ + +import PDFDocument from "pdfkit"; +import { + getClinicById, + getAdvancedAnalytics, + getConsultationStats, +} from "../db.js"; +import { childLogger } from "../_core/logger.js"; + +const log = childLogger("pdf-report"); + +// QueueMed medical theme +const COLOR_PRIMARY = "#10b981"; // emerald-500 +const COLOR_ACCENT = "#06b6d4"; // cyan-500 +const COLOR_TEAL = "#0d9488"; // teal-600 +const COLOR_TEXT = "#0f172a"; // slate-900 +const COLOR_MUTED = "#64748b"; // slate-500 +const COLOR_BORDER = "#e2e8f0"; // slate-200 +const COLOR_BG_SOFT = "#f0fdf4"; // green-50 + +const REASON_LABELS: Record = { + consultation: "Consultation", + urgence: "Urgence", + certificat_scolaire: "Certificat scolaire", + certificat_sportif: "Certificat sportif", + arret_travail: "Arrêt de travail", + administratif: "Administratif", + autre: "Autre", +}; + +function formatDate(d: Date): string { + return d.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "long", + year: "numeric", + }); +} + +export async function generateQueueReport( + clinicId: number, + dateRange: { days: number } +): Promise { + const days = Math.max(1, Math.min(dateRange.days ?? 30, 365)); + const clinic = await getClinicById(clinicId); + if (!clinic) throw new Error(`Clinic ${clinicId} introuvable`); + + // Fetch the same data the dashboard renders so the PDF matches the UI. + const [advanced, consult] = await Promise.all([ + getAdvancedAnalytics(clinic.userId, { days, clinicId }), + getConsultationStats(clinicId, days), + ]); + + const now = new Date(); + const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + const doc = new PDFDocument({ + size: "A4", + margin: 48, + info: { + Title: `QueueMed — Rapport ${clinic.name}`, + Author: "QueueMed", + Subject: "Rapport file d'attente", + Creator: "QueueMed", + }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (c: Buffer) => chunks.push(c)); + const finished = new Promise((resolve, reject) => { + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + }); + + // ─── Header ──────────────────────────────────────────────────────────────── + doc + .rect(0, 0, doc.page.width, 90) + .fill(COLOR_PRIMARY); + doc + .fillColor("#ffffff") + .fontSize(22) + .font("Helvetica-Bold") + .text("QueueMed — Rapport file d'attente", 48, 28); + doc + .fontSize(11) + .font("Helvetica") + .fillColor("#ecfeff") + .text(`Cabinet : ${clinic.name}`, 48, 58); + doc.text( + `Période : ${formatDate(since)} → ${formatDate(now)} (${days} j)`, + 48, + 72 + ); + + doc.fillColor(COLOR_TEXT); + doc.y = 120; + doc.x = 48; + + // ─── Summary table ───────────────────────────────────────────────────────── + doc + .fontSize(14) + .font("Helvetica-Bold") + .fillColor(COLOR_TEAL) + .text("Résumé", { underline: false }); + doc.moveDown(0.4); + + const totalJoined = advanced.totalJoined; + const totalServed = advanced.totalServed; + const totalAbsent = advanced.totalAbsent; + const noShowPct = Math.round((advanced.noShowRate ?? 0) * 100); + const totalConsults = consult.totalConsultations; + const avgConsultMin = consult.avgDurationMinutes; + const presenceRate = consult.presenceRate; + + // Compute average wait from byHour aggregates is not direct — use avgWaitByDay + const allDays = advanced.avgWaitByDay; + const totalWait = allDays.reduce( + (acc, d) => acc + d.avgWaitMinutes * d.count, + 0 + ); + const totalWaitCount = allDays.reduce((acc, d) => acc + d.count, 0); + const avgWaitMin = totalWaitCount > 0 ? Math.round(totalWait / totalWaitCount) : 0; + + const summaryRows: Array<[string, string]> = [ + ["Patients inscrits", String(totalJoined)], + ["Patients servis", String(totalServed)], + ["Patients absents", String(totalAbsent)], + ["Taux d'absence", `${noShowPct}%`], + ["Taux de présence", `${presenceRate}%`], + ["Attente moyenne", `${avgWaitMin} min`], + ["Consultations terminées", String(totalConsults)], + ["Durée moy. consultation", `${avgConsultMin} min`], + ]; + + drawKeyValueTable(doc, summaryRows); + + doc.moveDown(1); + + // ─── By-hour breakdown ───────────────────────────────────────────────────── + doc + .fontSize(14) + .font("Helvetica-Bold") + .fillColor(COLOR_TEAL) + .text("Affluence par heure"); + doc.moveDown(0.4); + + const hourRows = advanced.byHour + .map((count, hour) => ({ hour, count })) + .filter((r) => r.count > 0); + + if (hourRows.length === 0) { + doc + .fontSize(10) + .font("Helvetica-Oblique") + .fillColor(COLOR_MUTED) + .text("Aucune donnée d'affluence sur la période."); + } else { + const maxCount = Math.max(...hourRows.map((r) => r.count)); + drawTableHeader(doc, ["Heure", "Patients", "Répartition"], [80, 80, 320]); + for (const r of hourRows) { + ensureSpace(doc, 22); + const y = doc.y; + doc + .font("Helvetica") + .fontSize(10) + .fillColor(COLOR_TEXT) + .text(`${r.hour.toString().padStart(2, "0")}h`, 48, y, { width: 80 }); + doc.text(String(r.count), 128, y, { width: 80 }); + // Bar + const barX = 208; + const barMaxW = 300; + const barW = Math.max(4, Math.round((r.count / maxCount) * barMaxW)); + doc.rect(barX, y + 3, barW, 8).fill(COLOR_PRIMARY); + doc.fillColor(COLOR_TEXT); + doc.y = y + 18; + } + } + + doc.moveDown(1); + + // ─── Top visit reasons ───────────────────────────────────────────────────── + doc + .fontSize(14) + .font("Helvetica-Bold") + .fillColor(COLOR_TEAL) + .text("Motifs de consultation"); + doc.moveDown(0.4); + + if (consult.topReasons.length === 0) { + doc + .fontSize(10) + .font("Helvetica-Oblique") + .fillColor(COLOR_MUTED) + .text("Aucun motif de consultation enregistré."); + } else { + drawTableHeader(doc, ["Motif", "Nombre", "Part"], [240, 80, 120]); + const topTotal = consult.topReasons.reduce((s, r) => s + r.count, 0); + for (const r of consult.topReasons) { + ensureSpace(doc, 18); + const y = doc.y; + const label = REASON_LABELS[r.reason] ?? r.reason; + const pct = topTotal > 0 ? Math.round((r.count / topTotal) * 100) : 0; + doc + .font("Helvetica") + .fontSize(10) + .fillColor(COLOR_TEXT) + .text(label, 48, y, { width: 240 }); + doc.text(String(r.count), 288, y, { width: 80 }); + doc.text(`${pct}%`, 368, y, { width: 120 }); + doc.y = y + 16; + } + } + + // ─── Footer ──────────────────────────────────────────────────────────────── + const footerY = doc.page.height - 36; + doc + .fontSize(9) + .font("Helvetica") + .fillColor(COLOR_MUTED) + .text( + `Généré par QueueMed le ${now.toLocaleString("fr-FR")}`, + 48, + footerY, + { align: "center", width: doc.page.width - 96 } + ); + + doc.end(); + + try { + return await finished; + } catch (err) { + log.error({ err, clinicId }, "PDF generation failed"); + throw err; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── +function ensureSpace(doc: PDFKit.PDFDocument, needed: number): void { + if (doc.y + needed > doc.page.height - 60) { + doc.addPage(); + } +} + +function drawKeyValueTable( + doc: PDFKit.PDFDocument, + rows: Array<[string, string]> +): void { + const colKeyW = 260; + const colValW = 240; + const x = 48; + for (const [k, v] of rows) { + ensureSpace(doc, 22); + const y = doc.y; + doc + .rect(x, y, colKeyW + colValW, 20) + .fill(COLOR_BG_SOFT) + .stroke(COLOR_BORDER); + doc + .font("Helvetica") + .fontSize(10) + .fillColor(COLOR_TEXT) + .text(k, x + 8, y + 6, { width: colKeyW - 16 }); + doc + .font("Helvetica-Bold") + .fillColor(COLOR_TEAL) + .text(v, x + colKeyW, y + 6, { width: colValW - 8, align: "right" }); + doc.y = y + 22; + } + doc.fillColor(COLOR_TEXT); +} + +function drawTableHeader( + doc: PDFKit.PDFDocument, + headers: string[], + widths: number[] +): void { + ensureSpace(doc, 22); + const x = 48; + const y = doc.y; + doc.rect(x, y, widths.reduce((a, b) => a + b, 0), 20).fill(COLOR_PRIMARY); + let cx = x; + for (let i = 0; i < headers.length; i++) { + doc + .font("Helvetica-Bold") + .fontSize(10) + .fillColor("#ffffff") + .text(headers[i], cx + 8, y + 6, { width: widths[i] - 16 }); + cx += widths[i]; + } + doc.fillColor(COLOR_TEXT); + doc.y = y + 22; +} diff --git a/server/services/sms.ts b/server/services/sms.ts new file mode 100644 index 0000000..6dd9fb0 --- /dev/null +++ b/server/services/sms.ts @@ -0,0 +1,73 @@ +/** + * SMS notification service using Twilio. + * + * Reads TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER from env. + * 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"; + +const log = childLogger("sms"); + +let cachedClient: Twilio | null = null; + +export function isSmsConfigured(): boolean { + return Boolean( + process.env.TWILIO_ACCOUNT_SID && + process.env.TWILIO_AUTH_TOKEN && + process.env.TWILIO_PHONE_NUMBER + ); +} + +function getClient(): Twilio | null { + if (cachedClient) return cachedClient; + if (!isSmsConfigured()) return null; + try { + cachedClient = twilio( + process.env.TWILIO_ACCOUNT_SID!, + process.env.TWILIO_AUTH_TOKEN! + ); + return cachedClient; + } 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 client = getClient(); + if (!client) { + log.warn({ to: to.slice(0, 4) + "***" }, "Twilio not configured — SMS skipped"); + return { success: false, error: "Twilio non configuré" }; + } + + const from = process.env.TWILIO_PHONE_NUMBER!; + const normalized = normalizePhone(to); + + try { + const msg = await client.messages.create({ + to: normalized, + 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 }; + } +}