diff --git a/README.md b/README.md index 4e20a379..5c2cda47 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ Open two terminals: npm run dev ``` +> On first boot the server automatically seeds all AI chat templates, SMS templates, and greeting messages for every user — no manual configuration needed. + **Terminal 2** — Selenium service: ```sh cd apps/SeleniumService diff --git a/apps/Backend/src/index.ts b/apps/Backend/src/index.ts index 1db5c359..08dcc65c 100755 --- a/apps/Backend/src/index.ts +++ b/apps/Backend/src/index.ts @@ -4,6 +4,7 @@ import http from "http"; import { initSocket } from "./socket"; import { startSeleniumWorker } from "./queue/workers/seleniumWorker"; import { startOcrWorker } from "./queue/workers/ocrWorker"; +import { seedAllUsersTemplates } from "./storage/seed-templates"; dotenv.config(); @@ -29,6 +30,9 @@ server.listen(PORT, HOST, () => { console.log( `✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}` ); + seedAllUsersTemplates().catch((err) => + console.error("⚠️ Template seed failed:", err) + ); }); // Handle startup errors diff --git a/apps/Backend/src/routes/office-contact.ts b/apps/Backend/src/routes/office-contact.ts index 29a3c55d..080885a6 100644 --- a/apps/Backend/src/routes/office-contact.ts +++ b/apps/Backend/src/routes/office-contact.ts @@ -22,7 +22,7 @@ router.put("/", async (req: Request, res: Response): Promise => { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { officeName, receptionistName, dentistName, phoneNumber, email, fax } = req.body; + const { officeName, receptionistName, dentistName, phoneNumber, email, fax, streetAddress, city, state, zipCode } = req.body; const record = await storage.upsertOfficeContact(userId, { officeName: officeName ?? undefined, receptionistName: receptionistName ?? undefined, @@ -30,6 +30,10 @@ router.put("/", async (req: Request, res: Response): Promise => { phoneNumber: phoneNumber ?? undefined, email: email ?? undefined, fax: fax ?? undefined, + streetAddress: streetAddress ?? undefined, + city: city ?? undefined, + state: state ?? undefined, + zipCode: zipCode ?? undefined, }); return res.status(200).json(record); } catch (err) { diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index b5bef69f..8f0a0a49 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -132,13 +132,19 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); } - // Resolve office name and reminder SMS template + // Resolve office name, address, and reminder SMS template const officeContact = await storage.getOfficeContact(userId); const officeName = (officeContact as any)?.officeName?.trim() || ""; + const officeAddress = [ + (officeContact as any)?.streetAddress?.trim(), + (officeContact as any)?.city?.trim(), + (officeContact as any)?.state?.trim(), + (officeContact as any)?.zipCode?.trim(), + ].filter(Boolean).join(", "); const chatTemplates = await storage.getAiChatTemplates(userId); const DEFAULT_REMINDER_SMS = - "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!"; + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!"; const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS; // Fetch appointments for the selected staff columns on the given date @@ -187,6 +193,7 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis const message = templateBody .replace(/\{firstName\}/g, patient.firstName ?? "") .replace(/\{officeName\}/g, officeName) + .replace(/\{officeAddress\}/g, officeAddress) .replace(/\{appointmentDate\}/g, apptDate) .replace(/\{appointmentTime\}/g, apptTime) .replace(/\{date\}/g, apptDate) @@ -222,6 +229,32 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis } }); +// GET /api/twilio/sms-template-list +router.get("/sms-template-list", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const list = await storage.getSmsTemplateList(userId); + return res.status(200).json(list); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch SMS templates", details: String(err) }); + } +}); + +// PUT /api/twilio/sms-template-list +router.put("/sms-template-list", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = req.body; + if (!Array.isArray(templates)) return res.status(400).json({ message: "Expected an array of templates" }); + await storage.saveSmsTemplateList(userId, templates); + return res.status(200).json({ ok: true }); + } catch (err) { + return res.status(500).json({ error: "Failed to save SMS templates", details: String(err) }); + } +}); + // GET /api/twilio/templates router.get("/templates", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/storage/office-contact-storage.ts b/apps/Backend/src/storage/office-contact-storage.ts index 7d402f13..aead576c 100644 --- a/apps/Backend/src/storage/office-contact-storage.ts +++ b/apps/Backend/src/storage/office-contact-storage.ts @@ -12,6 +12,10 @@ export const officeContactStorage = { phoneNumber?: string; email?: string; fax?: string; + streetAddress?: string; + city?: string; + state?: string; + zipCode?: string; }) { return db.officeContact.upsert({ where: { userId }, diff --git a/apps/Backend/src/storage/seed-templates.ts b/apps/Backend/src/storage/seed-templates.ts new file mode 100644 index 00000000..319e8e09 --- /dev/null +++ b/apps/Backend/src/storage/seed-templates.ts @@ -0,0 +1,65 @@ +import { prisma as db } from "@repo/db/client"; + +// ── Default template values ─────────────────────────────────────────────────── +// Keep these in sync with the frontend constants in ai-chat-settings-card.tsx + +export const DEFAULT_REMINDER_SMS = + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!"; + +export const DEFAULT_AI_CHAT_TEMPLATES = { + _ai_chat_reminder_sms: DEFAULT_REMINDER_SMS, + _ai_chat_reminder_greeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply you message at any time you need.", + _ai_chat_new_patient_greeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?", + _ai_chat_general_fallback: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?", + _ai_chat_reschedule_greeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you find a new appointment time that works for you. Would you like to reschedule your appointment?", +}; + +export const DEFAULT_SMS_TEMPLATE_LIST = JSON.stringify([ + { + id: "default-appt-reminder", + name: "Appointment Reminder SMS", + body: DEFAULT_REMINDER_SMS, + }, + { + id: "default-follow-up", + name: "Follow up reminder", + body: "Hi {firstName}, this is a follow-up from {officeName}. We wanted to check in with you after your recent appointment. Please don't hesitate to call us if you have any questions.", + }, +]); + +// ── Auto-seed for a single user ─────────────────────────────────────────────── +// Writes defaults only for keys that are not yet set, so existing data is never +// overwritten. Safe to call on every boot. + +export async function seedTemplatesForUser(userId: number): Promise { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + + const patch: Record = {}; + + for (const [key, value] of Object.entries(DEFAULT_AI_CHAT_TEMPLATES)) { + if (!existing[key]) patch[key] = value; + } + if (!existing["_sms_template_list"]) { + patch["_sms_template_list"] = DEFAULT_SMS_TEMPLATE_LIST; + } + + if (Object.keys(patch).length === 0) return; // nothing to seed + + const updated = { ...existing, ...patch }; + await db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); +} + +// ── Seed all existing users ─────────────────────────────────────────────────── + +export async function seedAllUsersTemplates(): Promise { + const users = await db.user.findMany({ select: { id: true } }); + await Promise.all(users.map((u: { id: number }) => seedTemplatesForUser(u.id))); + if (users.length > 0) { + console.log(`✅ Seeded AI chat templates for ${users.length} user(s)`); + } +} diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 70e3a3cc..669b3777 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -87,6 +87,30 @@ export const twilioStorage = { }); }, + async getSmsTemplateList(userId: number): Promise<{ id: string; name: string; body: string }[]> { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const all = (settings?.templates as Record) || {}; + const raw = all["_sms_template_list"]; + if (!raw) return []; + try { return JSON.parse(raw); } catch { return []; } + }, + + async saveSmsTemplateList(userId: number, templates: { id: string; name: string; body: string }[]) { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + const updated: Record = { + ...existing, + "_sms_template_list": JSON.stringify(templates), + }; + // Keep _ai_chat_reminder_sms in sync with the first template for batch sends + if (templates.length > 0) updated["_ai_chat_reminder_sms"] = templates[0]!.body; + return db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); + }, + async getRecentCommunicationsByUser(userId: number, limit = 20) { return db.communication.findMany({ where: { patient: { userId } }, diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index 46162725..80883949 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -5,11 +5,13 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork } from "lucide-react"; +import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; // ─── Types ──────────────────────────────────────────────────────────────────── +type SmsTemplate = { id: string; name: string; body: string }; + type AiChatTemplates = { reminderGreeting: string; newPatientGreeting: string; @@ -22,18 +24,39 @@ type OfficeContact = { // ─── Defaults ───────────────────────────────────────────────────────────────── +const DEFAULT_REMINDER_SMS = + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!"; + const DEFAULTS = { reminderGreeting: - "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply your message at any time you need.", + "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. I will reply you message at any time you need.", newPatientGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?", - generalFallback: "How can I help you today?", + generalFallback: + "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?", }; +const DEFAULT_SMS_TEMPLATES = [ + { + id: "default-appt-reminder", + name: "Appointment Reminder SMS", + body: DEFAULT_REMINDER_SMS, + }, + { + id: "default-follow-up", + name: "Follow up reminder", + body: "Hi {firstName}, this is a follow-up from {officeName}. We wanted to check in with you after your recent appointment. Please don't hesitate to call us if you have any questions.", + }, +]; + function previewTemplate(text: string, officeName: string) { return text.replace(/\{officeName\}/g, officeName || "your dental office"); } +function newId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2); +} + // ─── LangGraph flow diagram (SVG) ───────────────────────────────────────────── function LangGraphFlow() { @@ -503,6 +526,10 @@ export function AiChatSettingsCard() { const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); const initialized = useRef(false); + // ── SMS template list ────────────────────────────────────────── + const [smsTemplates, setSmsTemplates] = useState([]); + const smsInitialized = useRef(false); + const { data: officeContact } = useQuery({ queryKey: ["/api/office-contact"], queryFn: async () => { @@ -524,7 +551,18 @@ export function AiChatSettingsCard() { refetchOnWindowFocus: false, }); - // Seed local state from server on first load only + const { data: smsTemplateListData, isLoading: smsLoading } = useQuery({ + queryKey: ["/api/twilio/sms-template-list"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/twilio/sms-template-list"); + if (!res.ok) return []; + return res.json(); + }, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + // Seed AI chat templates useEffect(() => { if (templates && !initialized.current) { initialized.current = true; @@ -534,6 +572,18 @@ export function AiChatSettingsCard() { } }, [templates]); + // Seed SMS template list — fall back to the saved reminderSms if list is empty + useEffect(() => { + if (smsTemplateListData && !smsInitialized.current) { + smsInitialized.current = true; + if (smsTemplateListData.length > 0) { + setSmsTemplates(smsTemplateListData); + } else { + setSmsTemplates(DEFAULT_SMS_TEMPLATES); + } + } + }, [smsTemplateListData, templates]); + const saveMutation = useMutation({ mutationFn: async (data: AiChatTemplates) => { const res = await apiRequest("PUT", "/api/ai/chat-templates", data); @@ -552,6 +602,24 @@ export function AiChatSettingsCard() { }, }); + const saveSmsListMutation = useMutation({ + mutationFn: async (list: SmsTemplate[]) => { + const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to save SMS templates"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/twilio/sms-template-list"] }); + toast({ title: "SMS template saved" }); + }, + onError: (err: any) => { + toast({ title: "Error", description: err?.message, variant: "destructive" }); + }, + }); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate({ @@ -561,6 +629,20 @@ export function AiChatSettingsCard() { }); }; + const updateSmsTemplate = (idx: number, field: "name" | "body", value: string) => { + setSmsTemplates((prev) => prev.map((t, i) => i === idx ? { ...t, [field]: value } : t)); + }; + + const deleteSmsTemplate = (idx: number) => { + const updated = smsTemplates.filter((_, i) => i !== idx); + setSmsTemplates(updated); + saveSmsListMutation.mutate(updated); + }; + + const addSmsTemplate = () => { + setSmsTemplates((prev) => [...prev, { id: newId(), name: "", body: "" }]); + }; + const officeName = officeContact?.officeName?.trim() || ""; const templateFields = [ @@ -596,6 +678,113 @@ export function AiChatSettingsCard() { return (
+ {/* ── Section 0: SMS Templates ─────────────────────────────── */} + + +
+
+ +

SMS Templates

+
+ +
+ +

+ Available variables:{" "} + {"{firstName}"}{" "} + {"{officeName}"}{" "} + {"{officeAddress}"}{" "} + {"{appointmentDate}"}{" "} + {"{appointmentTime}"} +

+ + {officeName && ( +
+ + + {"{officeName}"} will display as{" "} + "{officeName}" + +
+ )} + + {smsLoading ? ( +

Loading...

+ ) : smsTemplates.length === 0 ? ( +

+ No templates yet. Click "Add Template" to create one. +

+ ) : ( +
+ {smsTemplates.map((tpl, idx) => ( +
+ {/* Name row */} +
+ {idx === 0 && ( + + Batch reminder + + )} + updateSmsTemplate(idx, "name", e.target.value)} + className="flex-1 p-2 border rounded text-sm font-medium min-w-0" + placeholder="Template name" + /> + +
+ + {/* Body */} +