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:
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user