fix: patient language field silently dropped by stale browser-side validation schema

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.
This commit is contained in:
ff
2026-08-05 21:40:53 -04:00
parent cef928be97
commit 55ce52f276
4 changed files with 62 additions and 12 deletions
+25 -2
View File
@@ -1,6 +1,7 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import twilio from "twilio"; import twilio from "twilio";
import { storage } from "../storage"; import { storage } from "../storage";
import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage";
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
import { runNewPatientStep } from "../ai/new-patient-graph"; import { runNewPatientStep } from "../ai/new-patient-graph";
import { import {
@@ -663,6 +664,25 @@ function voiceLocaleFor(language: string): VoiceLocale {
return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; 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<string, string> = {
Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?",
};
const RECALL_GREETING_TRANSLATIONS: Record<string, string> = {
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<string, string>, language: string): string {
if (rawTemplate.trim() !== defaultTemplate) return rawTemplate;
return translations[language] ?? defaultTemplate;
}
const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string {
@@ -738,8 +758,11 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
aiCallSessions.set(CallSid, session); aiCallSessions.set(CallSid, session);
const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) || const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) ||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?"; (isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING);
const greeting = rawCallTemplate const callTemplate = isRecallCall
? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language)
: resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language);
const greeting = callTemplate
.replace(/\{firstName\}/g, patient.firstName || "there") .replace(/\{firstName\}/g, patient.firstName || "there")
.replace(/\{officeName\}/g, officeName || "our office"); .replace(/\{officeName\}/g, officeName || "our office");
+9 -4
View File
@@ -1,5 +1,12 @@
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
// Exported so twilio-webhooks.ts can detect an admin-customized template
// (vs. this untouched default) and swap in a translated greeting instead.
export const DEFAULT_GREETING =
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
export const DEFAULT_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?";
export type TwilioSettingsData = { export type TwilioSettingsData = {
accountSid: string; accountSid: string;
authToken: string; authToken: string;
@@ -104,13 +111,11 @@ export const twilioStorage = {
const settings = await db.twilioSettings.findUnique({ where: { userId } }); const settings = await db.twilioSettings.findUnique({ where: { userId } });
const all = (settings?.templates as Record<string, string>) || {}; const all = (settings?.templates as Record<string, string>) || {};
return { return {
greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? DEFAULT_GREETING,
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.", 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.", 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?", generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?",
recallGreeting: all["_ai_call_recall_greeting"] ?? recallGreeting: all["_ai_call_recall_greeting"] ?? DEFAULT_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?",
}; };
}, },
@@ -1,6 +1,7 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { import {
Form, Form,
FormControl, FormControl,
@@ -50,6 +51,7 @@ export type PatientFormRef = {
export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>( export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
({ patient, extractedInfo, onSubmit }, ref) => { ({ patient, extractedInfo, onSubmit }, ref) => {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast();
const isEditing = !!patient; const isEditing = !!patient;
const schema = useMemo( const schema = useMemo(
@@ -83,7 +85,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
const computedDefaultValues = useMemo(() => { const computedDefaultValues = useMemo(() => {
if (isEditing && patient) { if (isEditing && patient) {
const { id, userId, createdAt, ...sanitizedPatient } = patient; const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient;
return { return {
...sanitizedPatient, ...sanitizedPatient,
dateOfBirth: patient.dateOfBirth dateOfBirth: patient.dateOfBirth
@@ -137,7 +139,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
useEffect(() => { useEffect(() => {
if (patient) { if (patient) {
const { id, userId, createdAt, ...sanitizedPatient } = patient; const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient;
const normalized = normalizeInsuranceProvider(patient.insuranceProvider); const normalized = normalizeInsuranceProvider(patient.insuranceProvider);
const resetValues: Partial<Patient> = { const resetValues: Partial<Patient> = {
...sanitizedPatient, ...sanitizedPatient,
@@ -164,9 +166,19 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
id="patient-form" id="patient-form"
key={patient?.id || "new"} key={patient?.id || "new"}
noValidate noValidate
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit(
handleSubmit2(data); (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" className="space-y-6"
> >
{/* Personal Information */} {/* Personal Information */}
+11 -1
View File
@@ -2,7 +2,7 @@ import * as z from "zod";
// ─── Enums ──────────────────────────────────────────────────────────────────── // ─── 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<typeof PatientStatusSchema>; export type PatientStatus = z.infer<typeof PatientStatusSchema>;
export const ClaimStatusSchema = z.enum([ export const ClaimStatusSchema = z.enum([
@@ -85,13 +85,23 @@ export const PatientUncheckedCreateInputObjectSchema = z.object({
firstName: z.string(), firstName: z.string(),
lastName: z.string(), lastName: z.string(),
dateOfBirth: z.string(), dateOfBirth: z.string(),
gender: z.string().optional(),
phone: z.string().optional(), phone: z.string().optional(),
email: z.string().optional(), email: z.string().optional(),
address: z.string().optional(), address: z.string().optional(),
city: z.string().optional(), city: z.string().optional(),
state: z.string().optional(), state: z.string().optional(),
zipCode: 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(), status: PatientStatusSchema.optional(),
userId: z.number().int().optional(),
createdAt: z.coerce.date().optional(),
}); });
export const StaffUncheckedCreateInputObjectSchema = z.object({ export const StaffUncheckedCreateInputObjectSchema = z.object({