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:
parent
198974717d
commit
bd580b849e
9 changed files with 739 additions and 50 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
|
|||
462
client/src/pages/AdminSettings.tsx
Normal file
462
client/src/pages/AdminSettings.tsx
Normal 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'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'environnement du serveur (non modifiables depuis l'interface).
|
||||
Contactez l'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'interface.
|
||||
Seule la dernière valeur saisie est visible. Laissez le champ vide pour conserver la valeur existante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
84
server/db.ts
84
server/db.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue