diff --git a/client/src/App.tsx b/client/src/App.tsx index 7920749..018b951 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { {/* Admin */} + + + diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 4aaf44d..6042801 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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 }] : []), ]; diff --git a/client/src/pages/AdminSettings.tsx b/client/src/pages/AdminSettings.tsx new file mode 100644 index 0000000..dbbcf1c --- /dev/null +++ b/client/src/pages/AdminSettings.tsx @@ -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("integrations"); + const [configMap, setConfigMap] = useState>({}); + const [editValues, setEditValues] = useState>({}); + const [showSecrets, setShowSecrets] = useState>({}); + const [saving, setSaving] = useState>({}); + + const configQuery = trpc.admin.listConfig.useQuery(undefined, { + onSuccess: (data: ConfigEntry[]) => { + const map: Record = {}; + const vals: Record = {}; + 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 ( +
+ +
+ ); + } + + if (!user || user.role !== "admin") { + return ; + } + + 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 ( +
+
+ + {desc &&

{desc}

} +
+ setEditValues((p) => ({ ...p, [keyName]: e.target.value }))} + /> + {isSecret && ( + + )} +
+
+
+ + {configMap[keyName] && ( + + )} +
+
+ ); + } + + return ( +
+ + Paramètres & Intégrations – QueueMed + + +
+
+
+ +
+
+

Paramètres & Intégrations

+

Configurez vos services et notifications

+
+
+ + {/* Tabs */} +
+ {TABS.map((t) => { + const active = tab === t.id; + return ( + + ); + })} +
+ + {/* Content */} +
+ {tab === "integrations" && ( +
+ {/* Stripe */} +
+
+
+ +

Stripe – Paiements

+
+ +
+ {CONFIG_KEYS.stripe.map((k) => ( + + ))} +
+ + {/* Twilio */} +
+
+
+ +

Twilio – SMS

+
+ +
+ {CONFIG_KEYS.twilio.map((k) => ( + + ))} +
+
+ )} + + {tab === "whatsapp" && } + {tab === "notifications" && } + {tab === "general" && } +
+
+
+ ); +} + +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 ( +
+
+ +

WhatsApp Business

+ + {isConnected ? "Connecté" : "Déconnecté"} + +
+ + {isConnected ? ( +
+ +

WhatsApp est connecté

+

Les notifications patients seront envoyées via WhatsApp

+ +
+ ) : qr ? ( +
+

+ Scannez ce QR code avec WhatsApp Business pour connecter votre compte +

+
+ QR Code WhatsApp +
+

+ WhatsApp → Appareils connectés → Connecter un appareil +

+
+ ) : ( +
+ +

WhatsApp n'est pas encore connecté

+ +
+ )} +
+ ); +} + +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 ( +
+
+ +

Canaux de notification

+
+ +

+ Activez ou désactivez les canaux de notification par cabinet. + WhatsApp doit être connecté (onglet WhatsApp) pour fonctionner. +

+ + {clinics.isLoading ? ( +
+ +
+ ) : ( +
+ {(clinics.data as any[])?.map((clinic: any) => ( +
+
+

{clinic.name}

+

{clinic.phone ?? "Pas de téléphone"}

+
+
+ + + WhatsApp {clinic.whatsappPhone ? "✓" : "non configuré"} + +
+
+ ))} +
+ )} +
+ ); +} + +function GeneralTab() { + return ( +
+
+ +

Paramètres généraux

+
+ +
+
+

💡 Astuce

+

+ Les paramètres de chaque cabinet (nom, adresse, horaires, rotation QR, langue patient...) + sont configurables depuis la page Cabinets du dashboard. +

+
+ +
+

📧 Email / SMTP

+

+ 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. +

+
+ +
+

🔐 Sécurité

+

+ 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. +

+
+
+
+ ); +} diff --git a/server/_core/index.ts b/server/_core/index.ts index bf6c1c4..4870bf7 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -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) { diff --git a/server/db.ts b/server/db.ts index c9c674f..3cb18fb 100644 --- a/server/db.ts +++ b/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(); +let configCacheLoaded = false; + +async function loadConfigCache(): Promise { + 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 { + 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 { + 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 { + 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 { + const db = await getDb(); + await db.delete(appConfig).where(eq(appConfig.key, key)); + configCache.delete(key); +} + +export function invalidateConfigCache(): void { + configCache.clear(); + configCacheLoaded = false; +} diff --git a/server/routers.ts b/server/routers.ts index e392eab..433881b 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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 ────────────────────────────────────────────────────────────── diff --git a/server/schema.ts b/server/schema.ts index 26c4379..ecbfe72 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -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; diff --git a/server/services/sms.ts b/server/services/sms.ts index 6dd9fb0..7c45458 100644 --- a/server/services/sms.ts +++ b/server/services/sms.ts @@ -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 { + 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"); diff --git a/server/services/stripe.ts b/server/services/stripe.ts index 67e362a..a863580 100644 --- a/server/services/stripe.ts +++ b/server/services/stripe.ts @@ -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 { + const dbKey = await getConfigValue("STRIPE_SECRET_KEY"); + if (dbKey) return dbKey; + return process.env.STRIPE_SECRET_KEY ?? null; +} + +export async function getStripe(): Promise { + 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 { + const key = await resolveStripeKey(); + return Boolean(key); +} + +async function resolvePriceId(envName: string, configKey: string): Promise { + 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 { 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 { 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 { - 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 { } } -export function verifyAndConstructEvent( +export async function verifyAndConstructEvent( rawBody: Buffer, signature: string -): Stripe.Event { - const stripe = getStripe(); - const secret = process.env.STRIPE_WEBHOOK_SECRET; +): Promise { + 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"); }