From 55ce52f27655cd9c7e3a7e6a27a63cb5fb6f3d8b Mon Sep 17 00:00:00 2001 From: ff Date: Wed, 5 Aug 2026 21:40:53 -0400 Subject: [PATCH] fix: patient language field silently dropped by stale browser-side validation schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/db/usedSchemas/browser.ts (the browser-resolved copy of the Patient zod schema, kept separate from the Node copy since it can't import Prisma-generated code) was missing several fields added to the real schema over time — preferredLanguage, gender, insuranceProvider, insuranceId, groupNumber, policyHolder, and the PLAN_NOT_ACCEPTED status. Since the schema isn't strict, those fields were silently stripped from every patient create/update in the browser with no validation error, so edits to them never reached the server. Also fixes the AI call greeting always playing in English regardless of the patient's preferred language — the greeting text was a single hardcoded string, unlike every later turn in the conversation which already branched by language. --- apps/Backend/src/routes/twilio-webhooks.ts | 27 +++++++++++++++++-- apps/Backend/src/storage/twilio-storage.ts | 13 ++++++--- .../src/components/patients/patient-form.tsx | 22 +++++++++++---- packages/db/usedSchemas/browser.ts | 12 ++++++++- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 277ce5fd..8932669d 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from "express"; import twilio from "twilio"; import { storage } from "../storage"; +import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage"; import { prisma as db } from "@repo/db/client"; import { runNewPatientStep } from "../ai/new-patient-graph"; import { @@ -663,6 +664,25 @@ function voiceLocaleFor(language: string): VoiceLocale { return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; } +// Spoken greeting is plain text, not LLM-generated, so it can't follow the +// patient's language on its own — translate it ourselves when the office +// hasn't overridden the default wording. Matches voiceLocaleFor's English/ +// Spanish-only voice coverage above. +const GREETING_TRANSLATIONS: Record = { + Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?", +}; +const RECALL_GREETING_TRANSLATIONS: Record = { + Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?", +}; + +/** Picks the greeting template to speak: the office's custom wording as-is if + * they've edited it (we can't auto-translate free text they wrote), otherwise + * the default translated into the patient's language when available. */ +function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record, language: string): string { + if (rawTemplate.trim() !== defaultTemplate) return rawTemplate; + return translations[language] ?? defaultTemplate; +} + const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { @@ -738,8 +758,11 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise) || {}; return { - greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? - "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?", + greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? DEFAULT_GREETING, reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.", newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.", generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?", - recallGreeting: all["_ai_call_recall_greeting"] ?? - "Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning, which most insurance plans cover at no cost. Would you like to schedule one?", + recallGreeting: all["_ai_call_recall_greeting"] ?? DEFAULT_RECALL_GREETING, }; }, diff --git a/apps/Frontend/src/components/patients/patient-form.tsx b/apps/Frontend/src/components/patients/patient-form.tsx index e9250afd..99c6b6bd 100755 --- a/apps/Frontend/src/components/patients/patient-form.tsx +++ b/apps/Frontend/src/components/patients/patient-form.tsx @@ -1,6 +1,7 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useAuth } from "@/hooks/use-auth"; +import { useToast } from "@/hooks/use-toast"; import { Form, FormControl, @@ -50,6 +51,7 @@ export type PatientFormRef = { export const PatientForm = forwardRef( ({ patient, extractedInfo, onSubmit }, ref) => { const { user } = useAuth(); + const { toast } = useToast(); const isEditing = !!patient; const schema = useMemo( @@ -83,7 +85,7 @@ export const PatientForm = forwardRef( const computedDefaultValues = useMemo(() => { if (isEditing && patient) { - const { id, userId, createdAt, ...sanitizedPatient } = patient; + const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient; return { ...sanitizedPatient, dateOfBirth: patient.dateOfBirth @@ -137,7 +139,7 @@ export const PatientForm = forwardRef( useEffect(() => { if (patient) { - const { id, userId, createdAt, ...sanitizedPatient } = patient; + const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient; const normalized = normalizeInsuranceProvider(patient.insuranceProvider); const resetValues: Partial = { ...sanitizedPatient, @@ -164,9 +166,19 @@ export const PatientForm = forwardRef( id="patient-form" key={patient?.id || "new"} noValidate - onSubmit={form.handleSubmit((data) => { - handleSubmit2(data); - })} + onSubmit={form.handleSubmit( + (data) => { + handleSubmit2(data); + }, + (errors) => { + console.log("❌ Form validation errors:", errors); + toast({ + title: "Could not save patient", + description: "Please check the highlighted fields and try again.", + variant: "destructive", + }); + }, + )} className="space-y-6" > {/* Personal Information */} diff --git a/packages/db/usedSchemas/browser.ts b/packages/db/usedSchemas/browser.ts index 3d140a39..84ac1ee4 100644 --- a/packages/db/usedSchemas/browser.ts +++ b/packages/db/usedSchemas/browser.ts @@ -2,7 +2,7 @@ import * as z from "zod"; // ─── Enums ──────────────────────────────────────────────────────────────────── -export const PatientStatusSchema = z.enum(["ACTIVE", "INACTIVE", "UNKNOWN"]); +export const PatientStatusSchema = z.enum(["ACTIVE", "INACTIVE", "UNKNOWN", "PLAN_NOT_ACCEPTED"]); export type PatientStatus = z.infer; export const ClaimStatusSchema = z.enum([ @@ -85,13 +85,23 @@ export const PatientUncheckedCreateInputObjectSchema = z.object({ firstName: z.string(), lastName: z.string(), dateOfBirth: z.string(), + gender: z.string().optional(), phone: z.string().optional(), email: z.string().optional(), address: z.string().optional(), city: z.string().optional(), state: z.string().optional(), zipCode: z.string().optional(), + insuranceProvider: z.string().optional().nullable(), + insuranceId: z.string().optional().nullable(), + groupNumber: z.string().optional().nullable(), + policyHolder: z.string().optional().nullable(), + allergies: z.string().optional().nullable(), + medicalConditions: z.string().optional().nullable(), + preferredLanguage: z.string().optional().nullable(), status: PatientStatusSchema.optional(), + userId: z.number().int().optional(), + createdAt: z.coerce.date().optional(), }); export const StaffUncheckedCreateInputObjectSchema = z.object({