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
This commit is contained in:
Hermes 2026-04-25 23:55:43 +00:00
parent 198974717d
commit bd580b849e
9 changed files with 739 additions and 50 deletions

View file

@ -28,6 +28,7 @@ import SubscriptionBlocked from "@/pages/SubscriptionBlocked";
import SubscriptionSuccess from "@/pages/SubscriptionSuccess";
import SubscriptionCancel from "@/pages/SubscriptionCancel";
import AdminPanel from "@/pages/AdminPanel";
import AdminSettings from "@/pages/AdminSettings";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
@ -105,6 +106,9 @@ export default function App() {
</Route>
{/* Admin */}
<Route path="/admin/settings">
<ProtectedRoute><AdminSettings /></ProtectedRoute>
</Route>
<Route path="/admin">
<ProtectedRoute><AdminPanel /></ProtectedRoute>
</Route>

View file

@ -3,7 +3,7 @@ import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next";
import {
LayoutDashboard, Building2, BarChart3, CreditCard,
HelpCircle, LogOut, Stethoscope, Menu, X, Shield,
HelpCircle, LogOut, Stethoscope, Menu, X, Shield, Settings,
} from "lucide-react";
import { useAuth } from "@/_core/hooks/useAuth";
import { cn } from "@/lib/utils";
@ -51,7 +51,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{ href: "/dashboard/subscription", label: t("nav.subscription"), icon: CreditCard },
{ href: "/help", label: t("nav.help"), icon: HelpCircle },
...(user?.role === "admin"
? [{ href: "/admin", label: "Admin", icon: Shield }]
? [{ href: "/admin", label: "Admin", icon: Shield }, { href: "/admin/settings", label: "Param\u00e8tres", icon: Settings }]
: []),
];

View file

@ -0,0 +1,462 @@
import { useState } from "react";
import { Helmet } from "react-helmet-async";
import { Redirect } from "wouter";
import {
Loader2,
CreditCard,
MessageSquare,
Phone,
Settings as SettingsIcon,
Save,
CheckCircle2,
Eye,
EyeOff,
Trash2,
TestTube,
} from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
type TabId = "integrations" | "whatsapp" | "notifications" | "general";
interface ConfigEntry {
key: string;
value: string;
isSecret: boolean;
category: string;
description?: string;
updatedAt: string;
}
const CONFIG_KEYS = {
stripe: [
{ key: "STRIPE_SECRET_KEY", label: "Clé secrète Stripe", secret: true, desc: "sk_live_... ou sk_test_..." },
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Clé publique Stripe", secret: false, desc: "pk_live_... ou pk_test_..." },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Secret Webhook Stripe", secret: true, desc: "whsec_..." },
{ key: "STRIPE_BASIC_PRICE_ID", label: "Price ID Basique", secret: false, desc: "price_..." },
{ key: "STRIPE_PRO_PRICE_ID", label: "Price ID Pro", secret: false, desc: "price_..." },
],
twilio: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", secret: false, desc: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", secret: true, desc: "Token Twilio" },
{ key: "TWILIO_PHONE_NUMBER", label: "Numéro Twilio", secret: false, desc: "+1..." },
],
};
export default function AdminSettings() {
const { user, loading } = useAuth();
const [tab, setTab] = useState<TabId>("integrations");
const [configMap, setConfigMap] = useState<Record<string, ConfigEntry>>({});
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState<Record<string, boolean>>({});
const configQuery = trpc.admin.listConfig.useQuery(undefined, {
onSuccess: (data: ConfigEntry[]) => {
const map: Record<string, ConfigEntry> = {};
const vals: Record<string, string> = {};
for (const row of data) {
map[row.key] = row;
vals[row.key] = row.isSecret && row.value === "••••••••" ? "" : row.value;
}
setConfigMap(map);
setEditValues(vals);
},
});
const setConfigMut = trpc.admin.setConfig.useMutation({
onSuccess: () => toast.success("Configuration sauvegardée"),
onError: (err: Error) => toast.error(err.message),
});
const deleteConfigMut = trpc.admin.deleteConfig.useMutation({
onSuccess: () => { toast.success("Clé supprimée"); configQuery.refetch(); },
onError: (err: Error) => toast.error(err.message),
});
const testStripeMut = trpc.admin.testStripeConnection.useMutation({
onSuccess: (r: { success: boolean; error?: string }) =>
r.success ? toast.success("Stripe : connexion OK ✓") : toast.error("Stripe : " + (r.error ?? "erreur")),
onError: (err: Error) => toast.error(err.message),
});
const testSmsMut = trpc.admin.testSmsConnection.useMutation({
onSuccess: (r: { success: boolean; error?: string }) =>
r.success ? toast.success("Twilio : connexion OK ✓") : toast.error("Twilio : " + (r.error ?? "erreur")),
onError: (err: Error) => toast.error(err.message),
});
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
</div>
);
}
if (!user || user.role !== "admin") {
return <Redirect to="/dashboard" />;
}
async function handleSave(key: string, category: string, isSecret: boolean, desc?: string) {
const val = editValues[key];
if (val === undefined || val === "") {
if (configMap[key]) {
deleteConfigMut.mutate({ key });
}
return;
}
setSaving((p) => ({ ...p, [key]: true }));
try {
await setConfigMut.mutateAsync({ key, value: val, isSecret, category, description: desc });
configQuery.refetch();
} finally {
setSaving((p) => ({ ...p, [key]: false }));
}
}
function handleDelete(key: string) {
if (confirm('Supprimer la clé "' + key + '" ?')) {
deleteConfigMut.mutate({ key });
}
}
const TABS: { id: TabId; label: string; icon: typeof SettingsIcon }[] = [
{ id: "integrations", label: "Intégrations", icon: CreditCard },
{ id: "whatsapp", label: "WhatsApp", icon: MessageSquare },
{ id: "notifications", label: "Notifications", icon: Phone },
{ id: "general", label: "Général", icon: SettingsIcon },
];
function ConfigField({ keyName, label, isSecret, category, desc }: {
keyName: string; label: string; isSecret: boolean; category: string; desc?: string;
}) {
const visible = !isSecret || showSecrets[keyName];
const hasValue = configMap[keyName] && configMap[keyName].value !== "••••••••";
return (
<div className="flex items-center gap-3 py-3 border-b border-slate-100 last:border-0">
<div className="flex-1 min-w-0">
<label className="block text-sm font-medium text-slate-700">{label}</label>
{desc && <p className="text-xs text-slate-400 mt-0.5">{desc}</p>}
<div className="relative mt-1">
<input
type={visible ? "text" : "password"}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-emerald-400 focus:ring-1 focus:ring-emerald-400 outline-none pr-9"
value={editValues[keyName] ?? ""}
placeholder={hasValue ? "•••••••• (laisser vide pour conserver)" : "Non configuré"}
onChange={(e) => setEditValues((p) => ({ ...p, [keyName]: e.target.value }))}
/>
{isSecret && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
onClick={() => setShowSecrets((p) => ({ ...p, [keyName]: !p[keyName] }))}
>
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="outline"
className="text-emerald-600 border-emerald-200 hover:bg-emerald-50"
disabled={saving[keyName]}
onClick={() => handleSave(keyName, category, isSecret, desc)}
>
{saving[keyName] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
</Button>
{configMap[keyName] && (
<Button
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDelete(keyName)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-emerald-50/30">
<Helmet>
<title>Paramètres & Intégrations QueueMed</title>
</Helmet>
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-emerald-100 rounded-xl">
<SettingsIcon className="w-6 h-6 text-emerald-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Paramètres & Intégrations</h1>
<p className="text-sm text-slate-500">Configurez vos services et notifications</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white rounded-xl p-1 shadow-sm border border-slate-100">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={
"flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all " +
(active
? "bg-emerald-500 text-white shadow-sm"
: "text-slate-500 hover:text-slate-700 hover:bg-slate-50")
}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
{/* Content */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
{tab === "integrations" && (
<div>
{/* Stripe */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<CreditCard className="w-5 h-5 text-violet-500" />
<h2 className="text-lg font-semibold text-slate-800">Stripe Paiements</h2>
</div>
<Button
size="sm"
variant="outline"
className="text-violet-600 border-violet-200 hover:bg-violet-50"
disabled={testStripeMut.isLoading}
onClick={() => testStripeMut.mutate()}
>
<TestTube className="w-3.5 h-3.5 mr-1" />
{testStripeMut.isLoading ? "Test..." : "Tester"}
</Button>
</div>
{CONFIG_KEYS.stripe.map((k) => (
<ConfigField key={k.key} keyName={k.key} label={k.label} isSecret={k.secret} category="stripe" desc={k.desc} />
))}
</div>
{/* Twilio */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Phone className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-800">Twilio SMS</h2>
</div>
<Button
size="sm"
variant="outline"
className="text-blue-600 border-blue-200 hover:bg-blue-50"
disabled={testSmsMut.isLoading}
onClick={() => testSmsMut.mutate()}
>
<TestTube className="w-3.5 h-3.5 mr-1" />
{testSmsMut.isLoading ? "Test..." : "Tester"}
</Button>
</div>
{CONFIG_KEYS.twilio.map((k) => (
<ConfigField key={k.key} keyName={k.key} label={k.label} isSecret={k.secret} category="twilio" desc={k.desc} />
))}
</div>
</div>
)}
{tab === "whatsapp" && <WhatsAppTab />}
{tab === "notifications" && <NotificationsTab />}
{tab === "general" && <GeneralTab />}
</div>
</div>
</div>
);
}
function WhatsAppTab() {
const status = trpc.whatsapp.getStatus.useQuery(undefined, { refetchInterval: 5000 });
const connectMut = trpc.whatsapp.connect.useMutation({
onSuccess: () => toast.success("Connexion WhatsApp lancée"),
onError: (err: Error) => toast.error(err.message),
});
const disconnectMut = trpc.whatsapp.disconnect.useMutation({
onSuccess: () => toast.success("WhatsApp déconnecté"),
onError: (err: Error) => toast.error(err.message),
});
const s = status.data as any;
const isConnected = s?.connected;
const qr = s?.qr;
return (
<div>
<div className="flex items-center gap-2 mb-6">
<MessageSquare className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-slate-800">WhatsApp Business</h2>
<span
className={
"ml-2 px-2 py-0.5 rounded-full text-xs font-medium " +
(isConnected ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500")
}
>
{isConnected ? "Connecté" : "Déconnecté"}
</span>
</div>
{isConnected ? (
<div className="text-center py-8">
<CheckCircle2 className="w-16 h-16 text-green-500 mx-auto mb-4" />
<p className="text-lg font-medium text-slate-700 mb-2">WhatsApp est connecté</p>
<p className="text-sm text-slate-500 mb-6">Les notifications patients seront envoyées via WhatsApp</p>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => disconnectMut.mutate()}
disabled={disconnectMut.isLoading}
>
Déconnecter
</Button>
</div>
) : qr ? (
<div className="text-center py-8">
<p className="text-sm text-slate-600 mb-4">
Scannez ce QR code avec WhatsApp Business pour connecter votre compte
</p>
<div className="inline-block p-4 bg-white rounded-xl border-2 border-slate-200 shadow-sm">
<img src={qr} alt="QR Code WhatsApp" className="w-64 h-64" />
</div>
<p className="text-xs text-slate-400 mt-4">
WhatsApp Appareils connectés Connecter un appareil
</p>
</div>
) : (
<div className="text-center py-8">
<MessageSquare className="w-16 h-16 text-slate-300 mx-auto mb-4" />
<p className="text-sm text-slate-500 mb-6">WhatsApp n&apos;est pas encore connecté</p>
<Button
className="bg-green-500 hover:bg-green-600 text-white"
onClick={() => connectMut.mutate()}
disabled={connectMut.isLoading}
>
{connectMut.isLoading ? "Connexion..." : "Connecter WhatsApp"}
</Button>
</div>
)}
</div>
);
}
function NotificationsTab() {
const clinics = trpc.clinic.list.useQuery();
const utils = trpc.useUtils();
const toggleSmsMut = trpc.clinicSettings.toggleSms.useMutation({
onSuccess: () => {
utils.clinic.list.invalidate();
toast.success("SMS mis à jour");
},
onError: (err: Error) => toast.error(err.message),
});
return (
<div>
<div className="flex items-center gap-2 mb-6">
<Phone className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-slate-800">Canaux de notification</h2>
</div>
<p className="text-sm text-slate-500 mb-4">
Activez ou désactivez les canaux de notification par cabinet.
WhatsApp doit être connecté (onglet WhatsApp) pour fonctionner.
</p>
{clinics.isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : (
<div className="space-y-3">
{(clinics.data as any[])?.map((clinic: any) => (
<div
key={clinic.id}
className="flex items-center justify-between p-4 rounded-xl bg-slate-50 border border-slate-100"
>
<div>
<p className="font-medium text-slate-700">{clinic.name}</p>
<p className="text-xs text-slate-400">{clinic.phone ?? "Pas de téléphone"}</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clinic.smsEnabled ?? false}
onChange={(e) =>
toggleSmsMut.mutate({ clinicId: clinic.id, enabled: e.target.checked })
}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-400"
/>
<span className="text-slate-600">SMS</span>
</label>
<span
className={
"text-xs px-2 py-0.5 rounded-full " +
(clinic.whatsappPhone
? "bg-green-100 text-green-700"
: "bg-slate-100 text-slate-400")
}
>
WhatsApp {clinic.whatsappPhone ? "✓" : "non configuré"}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
function GeneralTab() {
return (
<div>
<div className="flex items-center gap-2 mb-6">
<SettingsIcon className="w-5 h-5 text-slate-500" />
<h2 className="text-lg font-semibold text-slate-800">Paramètres généraux</h2>
</div>
<div className="space-y-4">
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200">
<p className="text-sm font-medium text-amber-800">💡 Astuce</p>
<p className="text-xs text-amber-700 mt-1">
Les paramètres de chaque cabinet (nom, adresse, horaires, rotation QR, langue patient...)
sont configurables depuis la page <strong>Cabinets</strong> du dashboard.
</p>
</div>
<div className="p-4 rounded-xl bg-blue-50 border border-blue-200">
<p className="text-sm font-medium text-blue-800">📧 Email / SMTP</p>
<p className="text-xs text-blue-700 mt-1">
La configuration SMTP se fait via les variables d&apos;environnement du serveur (non modifiables depuis l&apos;interface).
Contactez l&apos;administrateur système pour modifier les paramètres email.
</p>
</div>
<div className="p-4 rounded-xl bg-slate-50 border border-slate-200">
<p className="text-sm font-medium text-slate-800">🔐 Sécurité</p>
<p className="text-xs text-slate-600 mt-1">
Les clés secrètes (Stripe, Twilio) sont stockées en base de données et masquées dans l&apos;interface.
Seule la dernière valeur saisie est visible. Laissez le champ vide pour conserver la valeur existante.
</p>
</div>
</div>
</div>
);
}

View file

@ -205,7 +205,7 @@ async function bootstrap() {
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
// If Stripe is not configured, silently acknowledge so the route never 500s.
if (!isStripeConfigured()) {
if (!(await isStripeConfigured())) {
res.status(200).json({ received: true, configured: false });
return;
}
@ -215,7 +215,7 @@ async function bootstrap() {
return;
}
try {
const event = verifyAndConstructEvent(req.body as Buffer, signature);
const event = await verifyAndConstructEvent(req.body as Buffer, signature);
await handleStripeWebhook(event);
res.status(200).json({ received: true });
} catch (err) {

View file

@ -12,12 +12,14 @@ import {
whatsappCountryCodes,
whatsappLogs,
clinicMembers,
appConfig,
type User,
type Subscription,
type Clinic,
type QueueEntry,
type AnalyticsEvent,
type ClinicMember,
type AppConfig,
type InsertUser,
type InsertClinic,
type InsertQueueEntry,
@ -36,6 +38,7 @@ let dbInstance: MySql2Database<{
whatsappCountryCodes: typeof whatsappCountryCodes;
whatsappLogs: typeof whatsappLogs;
clinicMembers: typeof clinicMembers;
appConfig: typeof appConfig;
}> | null = null;
export async function getDb() {
@ -55,7 +58,7 @@ export async function getDb() {
});
dbInstance = drizzle(pool, {
schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs, clinicMembers },
schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs, clinicMembers, appConfig },
mode: "default",
});
@ -973,3 +976,82 @@ export async function getAdvancedAnalytics(
avgWaitByDay,
};
}
// ─── App Config (intégrations dynamiques) ────────────────────────────────────
// Cache en mémoire pour éviter une requête DB par appel. Invalidé sur écriture.
const configCache = new Map<string, string>();
let configCacheLoaded = false;
async function loadConfigCache(): Promise<void> {
const db = await getDb();
const rows = await db.select().from(appConfig);
configCache.clear();
for (const row of rows) {
configCache.set(row.key, row.value);
}
configCacheLoaded = true;
}
export async function getConfigValue(key: string): Promise<string | null> {
if (!configCacheLoaded) {
try {
await loadConfigCache();
} catch (err) {
childLogger("config").warn({ err, key }, "config cache load failed");
return null;
}
}
return configCache.get(key) ?? null;
}
export async function listAllConfig(): Promise<AppConfig[]> {
const db = await getDb();
const rows = await db.select().from(appConfig).orderBy(asc(appConfig.category), asc(appConfig.key));
return rows;
}
export async function setConfigValue(
key: string,
value: string,
category: string,
isSecret: boolean,
description?: string | null
): Promise<void> {
const db = await getDb();
const existing = await db
.select()
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
if (existing.length > 0) {
await db
.update(appConfig)
.set({
value,
category,
isSecret: isSecret ? 1 : 0,
description: description ?? existing[0].description,
})
.where(eq(appConfig.key, key));
} else {
await db.insert(appConfig).values({
key,
value,
category,
isSecret: isSecret ? 1 : 0,
description: description ?? null,
});
}
configCache.set(key, value);
}
export async function deleteConfigValue(key: string): Promise<void> {
const db = await getDb();
await db.delete(appConfig).where(eq(appConfig.key, key));
configCache.delete(key);
}
export function invalidateConfigCache(): void {
configCache.clear();
configCacheLoaded = false;
}

View file

@ -55,6 +55,9 @@ import {
removeClinicMember,
updateClinicMember,
getAdvancedAnalytics,
listAllConfig,
setConfigValue,
deleteConfigValue,
} from "./db.js";
import { whatsappCountryCodes } from "./schema.js";
import {
@ -88,6 +91,7 @@ import {
createCheckoutSession,
createPortalSession,
isStripeConfigured,
getStripe,
} from "./services/stripe.js";
import { checkPlanLimit, getPlanLimitsForUser } from "./services/planLimits.js";
import { isSmsConfigured, sendSms } from "./services/sms.js";
@ -424,7 +428,7 @@ const clinicRouter = router({
if (!clinic || clinic.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" });
}
if (input.smsEnabled && !isSmsConfigured()) {
if (input.smsEnabled && !(await isSmsConfigured())) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Twilio n'est pas configuré sur ce serveur.",
@ -1329,8 +1333,8 @@ const analyticsRouter = router({
// ─── Notification router (SMS Twilio) ────────────────────────────────────────
const notificationRouter = router({
smsStatus: protectedProcedure.query(() => {
return { configured: isSmsConfigured() };
smsStatus: protectedProcedure.query(async () => {
return { configured: await isSmsConfigured() };
}),
sendSms: subscriptionProcedure
@ -1341,7 +1345,7 @@ const notificationRouter = router({
})
)
.mutation(async ({ input, ctx }) => {
if (!isSmsConfigured()) {
if (!(await isSmsConfigured())) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Twilio n'est pas configuré sur ce serveur.",
@ -1419,7 +1423,7 @@ const subscriptionRouter = router({
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
const sub = await getSubscription(ctx.user.id);
const { plan, limits } = await getPlanLimitsForUser(ctx.user.id);
const stripeReady = isStripeConfigured();
const stripeReady = await isStripeConfigured();
return {
plan,
status: sub?.status ?? null,
@ -1722,6 +1726,18 @@ const clinicSettingsRouter = router({
clinicName: clinic.name,
};
}),
toggleSms: protectedProcedure
.input(z.object({ clinicId: z.number().int().positive(), enabled: 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" });
}
await updateClinic(input.clinicId, { smsEnabled: input.enabled });
return { success: true, smsEnabled: input.enabled };
}),
});
// ─── Consultation history router ─────────────────────────────────────────────
@ -1856,6 +1872,73 @@ const adminRouter = router({
activeWhatsAppSessions: getActiveWhatsAppSessionsCount(),
};
}),
// --- Admin Config (integrations & secrets) ---
listConfig: adminProcedure
.query(async () => {
const rows = await listAllConfig();
return rows.map((r) => ({
key: r.key,
value: r.isSecret ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : r.value,
isSecret: Boolean(r.isSecret),
category: r.category,
description: r.description,
updatedAt: r.updatedAt,
}));
}),
setConfig: adminProcedure
.input(
z.object({
key: z.string().max(100),
value: z.string(),
isSecret: z.boolean().default(false),
category: z.string().max(50),
description: z.string().max(255).optional(),
})
)
.mutation(async ({ input }) => {
await setConfigValue(input.key, input.value, {
isSecret: input.isSecret,
category: input.category,
description: input.description,
});
return { success: true };
}),
deleteConfig: adminProcedure
.input(z.object({ key: z.string().max(100) }))
.mutation(async ({ input }) => {
await deleteConfigValue(input.key);
return { success: true };
}),
testStripeConnection: adminProcedure
.mutation(async () => {
try {
const configured = await isStripeConfigured();
if (!configured) return { success: false, error: "Cle Stripe non configuree" };
const stripe = await getStripe();
await stripe.products.list({ limit: 1 });
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
}),
testSmsConnection: adminProcedure
.mutation(async () => {
try {
const configured = await isSmsConfigured();
if (!configured) return { success: false, error: "Twilio non configure" };
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
}),
});
// ─── App router ──────────────────────────────────────────────────────────────

View file

@ -4,6 +4,7 @@ import {
mysqlTable,
text,
timestamp,
tinyint,
varchar,
boolean,
json,
@ -279,3 +280,28 @@ export const clinicMembers = mysqlTable(
export type ClinicMember = typeof clinicMembers.$inferSelect;
export type InsertClinicMember = typeof clinicMembers.$inferInsert;
// ─── App Config (intégrations & secrets dynamiques) ──────────────────────────
// Permet de configurer Stripe / Twilio / autres services depuis l'UI admin
// sans redéploiement. Les valeurs marquées comme secrètes sont masquées
// côté API et ne sont jamais renvoyées en clair (sauf via les services
// internes qui les consomment).
export const appConfig = mysqlTable(
"app_config",
{
id: int("id").primaryKey().autoincrement(),
key: varchar("key", { length: 100 }).notNull(),
value: text("value").notNull(),
isSecret: tinyint("is_secret").default(0).notNull(),
category: varchar("category", { length: 50 }).notNull(),
description: varchar("description", { length: 255 }),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
},
(table) => ({
keyIdx: uniqueIndex("app_config_key_idx").on(table.key),
categoryIdx: index("app_config_category_idx").on(table.category),
})
);
export type AppConfig = typeof appConfig.$inferSelect;
export type InsertAppConfig = typeof appConfig.$inferInsert;

View file

@ -1,36 +1,54 @@
/**
* 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.
* 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;
export function isSmsConfigured(): boolean {
return Boolean(
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_PHONE_NUMBER
);
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,
};
}
function getClient(): Twilio | null {
if (cachedClient) return cachedClient;
if (!isSmsConfigured()) return 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(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!
);
return cachedClient;
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;
@ -48,19 +66,18 @@ export async function sendSms(
to: string,
body: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const client = getClient();
if (!client) {
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 from = process.env.TWILIO_PHONE_NUMBER!;
const normalized = normalizePhone(to);
try {
const msg = await client.messages.create({
const msg = await ctx.client.messages.create({
to: normalized,
from,
from: ctx.from,
body,
});
log.info({ messageId: msg.sid, to: normalized.slice(0, 4) + "***" }, "SMS sent");

View file

@ -1,25 +1,40 @@
import Stripe from "stripe";
import { eq, desc } from "drizzle-orm";
import { getDb, getSubscription, getUserById, updateSubscription } from "../db.js";
import { getDb, getSubscription, getUserById, updateSubscription, getConfigValue } from "../db.js";
import { subscriptions } from "../schema.js";
let stripeInstance: Stripe | null = null;
let stripeInstanceKey: string | null = null;
export function getStripe(): Stripe {
if (stripeInstance) return stripeInstance;
const key = process.env.STRIPE_SECRET_KEY;
async function resolveStripeKey(): Promise<string | null> {
const dbKey = await getConfigValue("STRIPE_SECRET_KEY");
if (dbKey) return dbKey;
return process.env.STRIPE_SECRET_KEY ?? null;
}
export async function getStripe(): Promise<Stripe> {
const key = await resolveStripeKey();
if (!key) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
if (stripeInstance && stripeInstanceKey === key) return stripeInstance;
stripeInstance = new Stripe(key, {
apiVersion: "2026-04-22.dahlia",
typescript: true,
});
stripeInstanceKey = key;
return stripeInstance;
}
export function isStripeConfigured(): boolean {
return Boolean(process.env.STRIPE_SECRET_KEY);
export async function isStripeConfigured(): Promise<boolean> {
const key = await resolveStripeKey();
return Boolean(key);
}
async function resolvePriceId(envName: string, configKey: string): Promise<string | null> {
const dbVal = await getConfigValue(configKey);
if (dbVal) return dbVal;
return process.env[envName] ?? null;
}
function publicBaseUrl(): string {
@ -35,7 +50,7 @@ async function ensureStripeCustomer(userId: number): Promise<string> {
if (sub?.stripeCustomerId) return sub.stripeCustomerId;
const user = await getUserById(userId);
if (!user) throw new Error("User not found");
const stripe = getStripe();
const stripe = await getStripe();
const customer = await stripe.customers.create({
email: user.email,
name: user.name ?? undefined,
@ -49,7 +64,7 @@ export async function createCheckoutSession(
userId: number,
priceId: string
): Promise<{ url: string; sessionId: string }> {
const stripe = getStripe();
const stripe = await getStripe();
const customerId = await ensureStripeCustomer(userId);
const base = publicBaseUrl();
const session = await stripe.checkout.sessions.create({
@ -67,7 +82,7 @@ export async function createCheckoutSession(
}
export async function createPortalSession(userId: number): Promise<{ url: string }> {
const stripe = getStripe();
const stripe = await getStripe();
const sub = await getSubscription(userId);
if (!sub?.stripeCustomerId) {
throw new Error("No Stripe customer for this user yet");
@ -100,8 +115,8 @@ export async function getSubscriptionStatus(userId: number): Promise<{
};
}
function planFromPriceId(priceId: string | null | undefined): "basic" | "pro" {
const proPriceId = process.env.STRIPE_PRO_PRICE_ID;
async function planFromPriceId(priceId: string | null | undefined): Promise<"basic" | "pro"> {
const proPriceId = await resolvePriceId("STRIPE_PRO_PRICE_ID", "STRIPE_PRO_PRICE_ID");
if (priceId && proPriceId && priceId === proPriceId) return "pro";
return "basic";
}
@ -163,7 +178,7 @@ async function syncSubscriptionFromStripe(
): Promise<void> {
const item = subscription.items.data[0];
const priceId = item?.price?.id ?? null;
const plan = planFromPriceId(priceId);
const plan = await planFromPriceId(priceId);
const status = mapStripeStatus(subscription.status);
await updateSubscription(userId, {
stripeSubscriptionId: subscription.id,
@ -181,7 +196,7 @@ async function syncSubscriptionFromStripe(
}
export async function handleWebhook(event: Stripe.Event): Promise<void> {
const stripe = getStripe();
const stripe = await getStripe();
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
@ -260,12 +275,12 @@ export async function handleWebhook(event: Stripe.Event): Promise<void> {
}
}
export function verifyAndConstructEvent(
export async function verifyAndConstructEvent(
rawBody: Buffer,
signature: string
): Stripe.Event {
const stripe = getStripe();
const secret = process.env.STRIPE_WEBHOOK_SECRET;
): Promise<Stripe.Event> {
const stripe = await getStripe();
const secret = (await getConfigValue("STRIPE_WEBHOOK_SECRET")) ?? process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET is not set");
}