feat: AI chat scheduling, claim automation, and session improvements

- Internal AI chat: schedule_appointment intent books earliest available
  slot in Column A using office hours; claim_only intent looks up latest
  past appointment for service date, asks user when two appointments are
  within 7 days, auto-triggers correct Selenium worker with mapped prices
- Gemini model updated to gemini-flash-latest; conversation history (15
  messages) passed for pronoun/reference resolution; history trimmed to
  start with user turn so Gemini doesn't reject the context
- Insurance alias file (insuranceAliases.json) replaces hardcoded siteKey
  matching; "tufs" now resolves to TUFTS_SCO
- DOB format normalized (MM/DD/YYYY → YYYY-MM-DD) before parseLocalDate;
  autoCheck now fires for all insurance types, not just MH/CMSP
- Claim form auto-submit: all handlers (MH, CCA, DDMA, UnitedDH, Tufts)
  accept formToUse and receive fee-schedule-priced form; prefillDone set
  after chatbot code prefill so autoSubmit gate opens correctly
- Chatbot: chat history persisted in sessionStorage, cleared on logout
  and auto-logout; Clear button writes fresh state synchronously; message
  history window increased to 15
- DentaQuest/TuftsSCO Selenium: "Remember me" checkbox clicked before
  sign-in to persist OTP trust cookie across sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-05 16:19:56 -04:00
parent ba2882957a
commit 1bbca38344
11 changed files with 693 additions and 94 deletions

View File

@@ -7,6 +7,8 @@ export type InternalChatIntent =
| "eligibility_by_id" // by explicit memberId + dob (no name)
| "check_and_claim" // eligibility + claim procedures
| "find_patient" // look up patient record only
| "schedule_appointment" // add patient to today's (or specified) schedule
| "claim_only" // submit claim for procedures (no eligibility check)
| "navigate_claims"
| "navigate_schedule"
| "general";
@@ -14,13 +16,16 @@ export type InternalChatIntent =
export interface ChatClassification {
intent: InternalChatIntent;
// --- patient resolution (one of name OR id+dob) ---
patientName?: string; // for check_eligibility / find_patient
patientName?: string; // for check_eligibility / find_patient / schedule_appointment
memberId?: string; // for eligibility_by_id / check_and_claim
dob?: string; // for eligibility_by_id / check_and_claim (MM/DD/YYYY)
// --- insurance hint (only if explicitly stated in the message) ---
insuranceHint?: string; // raw text, e.g. "masshealth", "BCBS", "CCA"
// --- procedures (raw text, NOT CDT codes — CDT lookup is done in workflow) ---
procedureNames?: string[]; // for check_and_claim, e.g. ["perio exam", "adult cleaning"]
// --- scheduling ---
appointmentDate?: string; // for schedule_appointment, YYYY-MM-DD (omit = today)
appointmentTime?: string; // for schedule_appointment, HH:MM 24h (omit = 09:00)
fallbackReply: string;
}
@@ -38,43 +43,61 @@ Respond ONLY with valid JSON (no markdown fences):
"dob": "<date of birth in MM/DD/YYYY if given>",
"insuranceHint": "<insurance name only if explicitly stated in the message, e.g. 'masshealth', 'BCBS MA', 'CCA'>",
"procedureNames": ["<raw procedure name>", ...],
"appointmentDate": "<YYYY-MM-DD if a specific date is mentioned, omit for today>",
"appointmentTime": "<HH:MM 24h if a specific time is mentioned, omit if not stated>",
"fallbackReply": "<1-2 sentence reply to show the user>"
}
Omit any field that is not present in the message.
Intents:
- check_eligibility : user wants to check insurance for a patient identified by NAME only
e.g. "check Maria Jesus", "verify insurance for John Smith"
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988"
- check_and_claim : user wants to check eligibility AND submit procedures as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110"
- find_patient : look up a patient record only, no eligibility
e.g. "find patient John", "look up Smith"
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- general : anything else
- check_eligibility : user wants to check insurance for a patient identified by NAME only
e.g. "check Maria Jesus", "verify insurance for John Smith"
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988"
- check_and_claim : user wants to check eligibility AND submit procedures as claims
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
e.g. "check Maria Jesus and claim D0120 D1110"
- find_patient : look up a patient record only, no eligibility
e.g. "find patient John", "look up Smith"
- schedule_appointment : add a patient to the schedule (today or a specified date/time)
e.g. "put John Smith in today's schedule"
e.g. "schedule Maria at 2pm tomorrow"
e.g. "add Jane Doe at 10:30"
- claim_only : submit a claim for procedures WITHOUT an eligibility check
e.g. "claim comprehensive exam and Pano for her"
e.g. "claim D0120 and D1110 for John Smith"
e.g. "bill adult cleaning for Maria"
Use this when no eligibility check is requested — just billing/claiming services
- navigate_claims : open the claims page
- navigate_schedule : open the appointments/schedule page
- general : anything else
Rules:
- For check_and_claim, procedureNames should be the RAW user text
- For check_and_claim and claim_only, procedureNames should be the RAW user text
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
- insuranceHint is only set when the user explicitly names an insurance in the message
- Keep fallbackReply to 1-2 sentences
- For navigate intents, fallbackReply = "Opening the [page] page..."`;
- For navigate intents, fallbackReply = "Opening the [page] page..."
- For schedule_appointment, appointmentDate omitted means today; appointmentTime omitted means no preference
- IMPORTANT: Use the conversation history to resolve pronouns and references.
If the user says "her", "him", "them", "the patient", or "same patient", look back through
the conversation history to find the patient name that was mentioned most recently.
Always populate patientName (or memberId) from history when a pronoun is used.
Never return an empty patientName just because the current message uses a pronoun.`;
// ─── Classifier ───────────────────────────────────────────────────────────────
export async function classifyInternalChat(
message: string,
apiKey: string,
extraSystemPrompt?: string
extraSystemPrompt?: string,
history: { role: "user" | "assistant"; text: string }[] = []
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
fallbackReply:
"I can search for a patient, check eligibility, run check & claim, or navigate to claims or appointments.",
"I can search for a patient, check eligibility, run check & claim, schedule appointments, or navigate to claims or appointments.",
};
if (!apiKey) return fallback;
@@ -84,9 +107,24 @@ export async function classifyInternalChat(
: BASE_SYSTEM_PROMPT;
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = new ChatGoogleGenerativeAI({ model: "gemini-flash-latest", apiKey });
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
const trimmedHistory = history.slice(
history.findIndex((h) => h.role === "user")
).filter((_, i, arr) => {
// Also drop consecutive same-role messages (keep last of each run)
if (i === arr.length - 1) return true;
return arr[i]!.role !== arr[i + 1]!.role;
});
const historyMessages = trimmedHistory.map((h) => ({
role: h.role,
content: h.text,
}));
const response = await llm.invoke([
{ role: "system", content: systemPrompt },
...historyMessages,
{ role: "user", content: message },
]);

View File

@@ -9,6 +9,7 @@
*/
import { ChatClassification } from "./internal-chat-graph";
import { lookupCdtCodes } from "./cdt-lookup";
import insuranceAliases from "../data/insuranceAliases.json";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -35,7 +36,10 @@ export interface ChatResponse {
| "check_eligibility_prefill"
| "eligibility_id_ready"
| "check_and_claim_ready"
| "need_insurance_clarification";
| "need_insurance_clarification"
| "appointment_created"
| "claim_only_ready"
| "need_appointment_selection";
actionData?: Record<string, any>;
}
@@ -43,13 +47,10 @@ export interface ChatResponse {
export function deriveSiteKey(provider: string): string {
const p = (provider ?? "").toLowerCase().trim();
if (!p || p.includes("masshealth") || p === "mh" || p === "mass health") return "MH";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
if (p.includes("delta dental ins") || p.includes("delta ins")) return "DELTA_INS";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TUFTS_SCO";
if ((p.includes("united") && p.includes("sco")) || p.includes("dentalhub") || p === "united_sco") return "UNITED_SCO";
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS_MA";
if (!p) return "MH";
for (const { keyword, siteKey } of insuranceAliases) {
if (p.includes(keyword.toLowerCase())) return siteKey;
}
return "MH";
}
@@ -74,6 +75,10 @@ interface StorageLike {
offset: number;
}): Promise<any[] | null>;
getPatientByInsuranceId(id: string): Promise<any | null>;
createAppointment(appointment: any): Promise<any>;
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
getOfficeHours(userId: number): Promise<any | null>;
getAppointmentsByPatientId(patientId: number): Promise<any[]>;
}
// ─── Shared helpers ───────────────────────────────────────────────────────────
@@ -210,6 +215,18 @@ export async function runInternalChatWorkflow(
return await handleCheckAndClaim(classification, storage, customAliases);
}
// ── Claim only (no eligibility check) ─────────────────────────────────────
if (intent === "claim_only") {
return await handleClaimOnly(classification, storage, customAliases);
}
// ── Schedule appointment ───────────────────────────────────────────────────
if (intent === "schedule_appointment") {
return await handleScheduleAppointment(classification, _userId, storage);
}
// ── General ────────────────────────────────────────────────────────────────
return { reply: classification.fallbackReply };
}
@@ -223,9 +240,9 @@ async function handleEligibilityById(
const memberId = c.memberId?.trim();
const dob = c.dob?.trim();
if (!memberId || !dob) {
if (!memberId) {
return {
reply: "Please provide both a Member ID and Date of Birth (MM/DD/YYYY).",
reply: "Please provide a Member ID to run the eligibility check.",
};
}
@@ -235,6 +252,14 @@ async function handleEligibilityById(
? patientToResult(existingPatient)
: null;
// Use stored DOB if not provided in the message
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) {
return {
reply: "Please provide a Date of Birth (MM/DD/YYYY) to run the eligibility check.",
};
}
// Determine siteKey
const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null,
@@ -250,7 +275,7 @@ async function handleEligibilityById(
action: "need_insurance_clarification",
actionData: {
memberId,
dob,
dob: resolvedDob,
patient,
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United/DentalHub"],
},
@@ -267,7 +292,7 @@ async function handleEligibilityById(
actionData: {
patient,
memberId,
dob,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
},
@@ -302,9 +327,12 @@ async function handleCheckAndClaim(
reply: "Please include either a Member ID or a patient name so I can look up their record.",
};
}
if (!dob) {
// Use stored DOB if not provided in the message
const resolvedDob = dob ?? patient?.dateOfBirth ?? null;
if (!resolvedDob) {
return {
reply: `I have the Member ID (${memberId}) but need a Date of Birth (MM/DD/YYYY) to run the eligibility check.`,
reply: `I have the Member ID (${memberId}) but couldn't find a Date of Birth on file. Please provide it (MM/DD/YYYY).`,
};
}
@@ -358,7 +386,7 @@ async function handleCheckAndClaim(
actionData: {
patient,
memberId,
dob,
dob: resolvedDob,
siteKey,
autoCheck: siteKeyToAutoCheck(siteKey),
cdtResults,
@@ -367,6 +395,269 @@ async function handleCheckAndClaim(
};
}
// ─── claim_only ───────────────────────────────────────────────────────────────
async function handleClaimOnly(
c: ChatClassification,
storage: StorageLike,
customAliases: { phrase: string; cdtCode: string }[]
): Promise<ChatResponse> {
// Resolve patient
let patient: ResolvedPatient | null = null;
if (c.memberId?.trim()) {
const existing = await storage.getPatientByInsuranceId(c.memberId.trim());
if (existing) patient = patientToResult(existing);
} else if (c.patientName?.trim()) {
const raw = await findPatientByName(c.patientName.trim(), storage);
if (raw) patient = patientToResult(raw);
}
if (!patient) {
return {
reply: "Please include a patient name or Member ID so I can look them up.",
};
}
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
// Map procedure names → CDT codes
const procedureNames = c.procedureNames ?? [];
if (procedureNames.length === 0) {
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
}
const cdtResults = lookupCdtCodes(procedureNames, customAliases);
const matched = cdtResults.filter((r) => r.code !== null);
const unmatched = cdtResults.filter((r) => r.code === null);
// Resolve service date: use explicit date from message, then latest appointment, then ask
let serviceDate: string | null = c.appointmentDate ?? null;
let appointmentId: number | null = null;
if (!serviceDate) {
const appts = await storage.getAppointmentsByPatientId(patient.id);
const sorted = appts.sort((a: any, b: any) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
if (sorted.length >= 2) {
const d1 = new Date(sorted[0].date).getTime();
const d2 = new Date(sorted[1].date).getTime();
const diffDays = Math.abs(d1 - d2) / (1000 * 60 * 60 * 24);
if (diffDays < 7) {
// Use UTC methods to avoid local-timezone day-shift on midnight UTC dates
const fmtUTC = (a: any) => {
const d = new Date(a.date);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`;
};
const isoUTC = (a: any) => {
const d = new Date(a.date);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
return {
reply: `Found two appointments close together for ${fullName}: ${fmtUTC(sorted[0])} and ${fmtUTC(sorted[1])}. Which date should I use for the claim?`,
action: "need_appointment_selection",
actionData: {
patient,
siteKey: resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH",
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
options: [
{ label: fmtUTC(sorted[0]), appointmentId: sorted[0].id, serviceDate: isoUTC(sorted[0]) },
{ label: fmtUTC(sorted[1]), appointmentId: sorted[1].id, serviceDate: isoUTC(sorted[1]) },
],
},
};
}
}
if (sorted.length > 0) {
const rawDate = new Date(sorted[0].date);
serviceDate = `${rawDate.getUTCFullYear()}-${String(rawDate.getUTCMonth() + 1).padStart(2, "0")}-${String(rawDate.getUTCDate()).padStart(2, "0")}`;
appointmentId = sorted[0].id ?? null;
}
}
if (!serviceDate) {
// No appointment on file — ask for the service date
const codesPreview = matched.map((r) => `${r.code}`).join(", ") || "the procedures";
return {
reply: `Found ${fullName} but no appointments on file. What was the service date for ${codesPreview}? (e.g. "6/2/2026")`,
};
}
const siteKey = resolveSiteKey(patient.insuranceProvider ?? null, c.insuranceHint ?? null) ?? "MH";
const [sy, sm, sd] = serviceDate.split("-");
const dateLabel = `${sm}/${sd}/${sy}`;
let reply = `Opening claim for ${fullName} (service date ${dateLabel}): ${
matched.map((r) => `${r.code} (${r.description})`).join(", ") || "no procedures matched"
}.`;
if (unmatched.length > 0) {
reply += ` Could not map: ${unmatched.map((r) => `"${r.input}"`).join(", ")} — verify manually.`;
}
return {
reply,
action: "claim_only_ready",
actionData: {
patient,
siteKey,
serviceDate,
appointmentId,
matchedCodes: matched.map((r) => ({ code: r.code!, description: r.description })),
},
};
}
// ─── schedule_appointment ─────────────────────────────────────────────────────
const DEFAULT_STAFF_ID = 1; // Column A
const SLOT_DURATION = 30; // minutes
/** Convert "HH:MM" to total minutes since midnight */
function toMinutes(t: string): number {
const [h, m] = t.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
/** Convert total minutes since midnight to "HH:MM" */
function fromMinutes(m: number): string {
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
/**
* Build the list of valid start minutes for a day using office hours.
* Skips the lunch gap (amEnd → pmStart).
*/
function buildSlots(dayHours: { amStart: string; amEnd: string; pmStart: string; pmEnd: string }): number[] {
const slots: number[] = [];
const ranges = [
[toMinutes(dayHours.amStart), toMinutes(dayHours.amEnd)],
[toMinutes(dayHours.pmStart), toMinutes(dayHours.pmEnd)],
];
for (const [start, end] of ranges) {
for (let t = start!; t + SLOT_DURATION <= end!; t += SLOT_DURATION) {
slots.push(t);
}
}
return slots;
}
async function handleScheduleAppointment(
c: ChatClassification,
userId: number,
storage: StorageLike
): Promise<ChatResponse> {
const name = c.patientName?.trim();
if (!name) {
return { reply: "Please include the patient's name so I can find them." };
}
const raw = await findPatientByName(name, storage);
if (!raw) {
return {
reply: `No patient found matching "${name}". Please check the spelling or add them on the Patients page first.`,
};
}
const patient = patientToResult(raw);
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
// Resolve date
const today = new Date();
const localDate = c.appointmentDate
? new Date(c.appointmentDate + "T00:00:00")
: new Date(today.getFullYear(), today.getMonth(), today.getDate());
const dateStr = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, "0")}-${String(localDate.getDate()).padStart(2, "0")}`;
const dateLabel = localDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
// If time was explicitly provided, use it directly
if (c.appointmentTime) {
const startTime = c.appointmentTime;
const startMin = toMinutes(startTime);
const endTime = fromMinutes(startMin + SLOT_DURATION);
await storage.createAppointment({
patientId: patient.id,
userId,
staffId: DEFAULT_STAFF_ID,
title: dateLabel,
date: localDate,
startTime,
endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
});
return {
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A).`,
action: "appointment_created",
actionData: { patient, date: dateStr, startTime, endTime },
};
}
// No time specified — find earliest available slot in Column A
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const dayName = dayNames[localDate.getDay()]!;
// Load office hours (fall back to 912 / 1317 if not configured)
const officeHours = await storage.getOfficeHours(userId);
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
amStart: "09:00", amEnd: "12:00", pmStart: "13:00", pmEnd: "17:00", enabled: true,
};
if (!dayHours.enabled) {
return { reply: `The office is closed on ${dayName}. Please choose a different day.` };
}
const allSlots = buildSlots(dayHours);
// Fetch all Column A appointments for that day
const existing = (await storage.getAppointmentsByDateForUser(dateStr, userId))
.filter((a: any) => a.staffId === DEFAULT_STAFF_ID);
// Find first slot that doesn't overlap any existing appointment
const booked = existing.map((a: any) => ({
start: toMinutes(a.startTime),
end: toMinutes(a.endTime),
}));
const availableStart = allSlots.find((slotStart) => {
const slotEnd = slotStart + SLOT_DURATION;
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
});
if (availableStart === undefined) {
return {
reply: `Column A is fully booked on ${dateLabel}. Please pick a different date or time.`,
};
}
const startTime = fromMinutes(availableStart);
const endTime = fromMinutes(availableStart + SLOT_DURATION);
await storage.createAppointment({
patientId: patient.id,
userId,
staffId: DEFAULT_STAFF_ID,
title: dateLabel,
date: localDate,
startTime,
endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
});
return {
reply: `Scheduled ${fullName} on ${dateLabel} at ${startTime} (Column A) — earliest available slot.`,
action: "appointment_created",
actionData: { patient, date: dateStr, startTime, endTime },
};
}
// ─── Insurance resolution helper ──────────────────────────────────────────────
/**

View File

@@ -0,0 +1,32 @@
[
{ "keyword": "masshealth", "siteKey": "MH" },
{ "keyword": "mass health", "siteKey": "MH" },
{ "keyword": "mh", "siteKey": "MH" },
{ "keyword": "commonwealth care alliance", "siteKey": "CCA" },
{ "keyword": "cca", "siteKey": "CCA" },
{ "keyword": "delta dental of massachusetts","siteKey": "DDMA" },
{ "keyword": "delta dental ma", "siteKey": "DDMA" },
{ "keyword": "ddma", "siteKey": "DDMA" },
{ "keyword": "delta dental ins", "siteKey": "DELTA_INS" },
{ "keyword": "delta ins", "siteKey": "DELTA_INS" },
{ "keyword": "delta dental", "siteKey": "DELTA_INS" },
{ "keyword": "tufts sco", "siteKey": "TUFTS_SCO" },
{ "keyword": "tufts", "siteKey": "TUFTS_SCO" },
{ "keyword": "tufs", "siteKey": "TUFTS_SCO" },
{ "keyword": "dentaquest", "siteKey": "TUFTS_SCO" },
{ "keyword": "tuftssco", "siteKey": "TUFTS_SCO" },
{ "keyword": "united healthone sco", "siteKey": "UNITED_SCO" },
{ "keyword": "united sco", "siteKey": "UNITED_SCO" },
{ "keyword": "dentalhub", "siteKey": "UNITED_SCO" },
{ "keyword": "united_sco", "siteKey": "UNITED_SCO" },
{ "keyword": "blue cross blue shield", "siteKey": "BCBS_MA" },
{ "keyword": "bcbs ma", "siteKey": "BCBS_MA" },
{ "keyword": "blue cross", "siteKey": "BCBS_MA" },
{ "keyword": "bcbs", "siteKey": "BCBS_MA" }
]

View File

@@ -165,7 +165,7 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { message } = req.body;
const { message, history } = req.body;
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
const aiSettings = await storage.getAiSettings(userId);
@@ -183,7 +183,8 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
const classification = await classifyInternalChat(
message.trim(),
aiSettings.apiKey,
extraSystemPrompt || undefined
extraSystemPrompt || undefined,
Array.isArray(history) ? history : []
);
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);

View File

@@ -79,6 +79,8 @@ interface ClaimFormProps {
patientId: number;
appointmentId?: number;
autoSubmit?: boolean;
/** When set, autoSubmit triggers this insurance's Selenium worker instead of MH */
autoSubmitSiteKey?: string;
/** When true: form saves to AppointmentProcedure (Select Procedures flow), shows only Save button */
proceduresOnly?: boolean;
onSubmit: (data: ClaimFormData) => Promise<Claim>;
@@ -101,6 +103,7 @@ export function ClaimForm({
patientId,
appointmentId,
autoSubmit,
autoSubmitSiteKey,
proceduresOnly = false,
onHandleAppointmentSubmit,
onHandleUpdatePatient,
@@ -487,6 +490,41 @@ export function ClaimForm({
};
}, [appointmentId, serviceDate, existingClaimId]);
// Prefill service lines (and optional service date) from chatbot claim_only flow
useEffect(() => {
const raw = sessionStorage.getItem("chatbot_claim_prefill");
if (!raw) return;
try {
const { codes, serviceDate } = JSON.parse(raw) as {
codes: { code: string; description: string }[];
serviceDate?: string;
};
sessionStorage.removeItem("chatbot_claim_prefill");
if (!codes?.length) return;
if (serviceDate) {
try {
const d = parseLocalDate(serviceDate);
setServiceDateValue(d);
setServiceDate(formatLocalDate(d));
} catch {}
}
setForm((prev) => {
const date = serviceDate ? serviceDate : prev.serviceDate;
const updatedLines = [...prev.serviceLines];
codes.forEach((c, i) => {
if (i < updatedLines.length) {
updatedLines[i] = { ...updatedLines[i]!, procedureCode: c.code, procedureDate: date };
}
});
return { ...prev, serviceLines: updatedLines };
});
if (!appointmentId) setPrefillDone(true);
} catch {}
}, []);
// Restore NPI provider from saved procedures when npiProviders list loads after 2b
useEffect(() => {
if (!savedProcNpiId || !npiProviders.length) return;
@@ -949,10 +987,11 @@ export function ClaimForm({
};
// 3rd Button workflow — CCA Claim: saves to DB then submits via Selenium
const handleCCAClaim = async () => {
const handleCCAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -963,7 +1002,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -990,10 +1029,10 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((f) => ({
filename: f.name,
mimeType: f.type,
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({
filename: file.name,
mimeType: file.type,
}));
const selectedNpiProviderId = npiProvider?.npiNumber
@@ -1014,7 +1053,7 @@ export function ClaimForm({
// Send to CCA Selenium — send raw YYYY-MM-DD so Python _format_dob converts correctly
onHandleForCCASeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1028,10 +1067,11 @@ export function ClaimForm({
};
// Delta MA Claim: saves to DB then submits via Selenium
const handleDDMAClaim = async () => {
const handleDDMAClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1042,7 +1082,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1068,7 +1108,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
// Upload files to server so we get local filePaths for Selenium
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
@@ -1091,7 +1131,7 @@ export function ClaimForm({
});
onHandleForDDMASeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1106,10 +1146,11 @@ export function ClaimForm({
};
// United/DentalHub Claim: saves to DB then submits via Selenium
const handleUnitedDHClaim = async () => {
const handleUnitedDHClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1120,7 +1161,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1146,7 +1187,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
@@ -1168,7 +1209,7 @@ export function ClaimForm({
});
onHandleForUnitedDHSeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1183,10 +1224,11 @@ export function ClaimForm({
};
// Tufts SCO Claim: saves to DB then submits via Selenium
const handleTuftsSCOClaim = async () => {
const handleTuftsSCOClaim = async (formToUse?: ClaimFormData & { uploadedFiles?: File[] }) => {
const f = formToUse ?? form;
const missingFields: string[] = [];
if (!form.memberId?.trim()) missingFields.push("Member ID");
if (!form.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!f.memberId?.trim()) missingFields.push("Member ID");
if (!f.dateOfBirth?.trim()) missingFields.push("Date of Birth");
if (!patient?.firstName?.trim()) missingFields.push("First Name");
if (missingFields.length > 0) {
toast({
@@ -1197,7 +1239,7 @@ export function ClaimForm({
return;
}
const filteredServiceLines = (form.serviceLines || []).filter(
const filteredServiceLines = (f.serviceLines || []).filter(
(line) => (line.procedureCode ?? "").trim() !== "",
);
if (filteredServiceLines.length === 0) {
@@ -1223,7 +1265,7 @@ export function ClaimForm({
}
}
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(uploadedFiles)
@@ -1255,7 +1297,7 @@ export function ClaimForm({
}
onHandleForTuftsSCOSeleniumClaim({
...form,
...f,
serviceLines: filteredServiceLines,
staffId: appointmentStaffId ?? Number(staff?.id),
patientId,
@@ -1656,8 +1698,30 @@ export function ClaimForm({
if (autoSubmittedRef.current) return;
autoSubmittedRef.current = true;
handleMHSubmit();
}, [autoSubmit, prefillDone, isFormReady]);
// Apply fee-schedule prices before triggering so billed amounts are populated
const siteKeyForPricing = autoSubmitSiteKey
? autoSubmitSiteKey.replace(/_/g, "").toLowerCase()
: deriveInsuranceSiteKey(form.insuranceProvider || "");
const pricedForm = mapPricesForForm({
form: { ...form, insuranceSiteKey: siteKeyForPricing },
patientDOB: patient?.dateOfBirth ?? "",
insuranceSiteKey: siteKeyForPricing,
});
const key = (autoSubmitSiteKey ?? "").toLowerCase();
if (key === "tufts_sco" || key === "tuftsco" || key === "tufts sco") {
handleTuftsSCOClaim(pricedForm);
} else if (key === "cca") {
handleCCAClaim(pricedForm);
} else if (key === "ddma") {
handleDDMAClaim(pricedForm);
} else if (key === "united_sco" || key === "unitedco" || key === "dentalhub") {
handleUnitedDHClaim(pricedForm);
} else {
handleMHSubmit(pricedForm);
}
}, [autoSubmit, autoSubmitSiteKey, prefillDone, isFormReady]);
// overlay click handler (close when clicking backdrop)
const onOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {

View File

@@ -9,6 +9,7 @@ import {
MessageSquare,
Send,
Loader2,
RotateCcw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -24,7 +25,8 @@ type Step =
| "patient-found"
| "eligibility-id-ready"
| "check-and-claim-ready"
| "need-insurance-clarification";
| "need-insurance-clarification"
| "need-appointment-selection";
interface Message {
id: number;
@@ -96,14 +98,20 @@ function parseEligibilityInput(
};
}
const INITIAL_MESSAGES: Message[] = [
makeMsg("bot", "Hi! What can I help you with today?"),
];
const CHAT_STORAGE_KEY = "chatbot_messages";
function loadSavedMessages(): Message[] {
try {
const raw = sessionStorage.getItem(CHAT_STORAGE_KEY);
if (raw) return JSON.parse(raw) as Message[];
} catch {}
return [makeMsg("bot", "Hi! What can I help you with today?")];
}
export function ChatbotButton() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<Step>("menu");
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [messages, setMessages] = useState<Message[]>(loadSavedMessages);
const [pasteInput, setPasteInput] = useState("");
const [parseError, setParseError] = useState("");
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
@@ -112,6 +120,12 @@ export function ChatbotButton() {
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null } | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
const [clarificationData, setClarificationData] = useState<{ memberId: string; dob: string; patient: PatientResult | null; procedureNames: string[]; options: string[] } | null>(null);
const [apptSelectionData, setApptSelectionData] = useState<{
patient: PatientResult;
siteKey: string;
matchedCodes: { code: string; description: string }[];
options: { label: string; appointmentId: number; serviceDate: string }[];
} | null>(null);
const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null);
@@ -121,6 +135,14 @@ export function ChatbotButton() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, step]);
// Persist messages across navigation (cleared on logout)
useEffect(() => {
try {
const saveable = messages.filter((m) => !m.isLoading);
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
} catch {}
}, [messages]);
useEffect(() => {
if (step === "eligibility-input") {
setTimeout(() => pasteRef.current?.focus(), 50);
@@ -141,9 +163,9 @@ export function ChatbotButton() {
return next;
});
const reset = () => {
// Resets step/data only — keeps message history
const resetStep = () => {
setStep("menu");
setMessages([makeMsg("bot", "Hi! What can I help you with today?")]);
setPasteInput("");
setParseError("");
setEligibilityData(null);
@@ -152,22 +174,33 @@ export function ChatbotButton() {
setEligibilityIdData(null);
setCheckAndClaimData(null);
setClarificationData(null);
setApptSelectionData(null);
};
// Full reset including message history and stored session
const reset = () => {
resetStep();
const fresh = [makeMsg("bot", "Hi! What can I help you with today?")];
try {
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
} catch {}
setMessages(fresh);
};
const handleClose = () => {
setOpen(false);
reset();
resetStep();
};
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
if (option === "schedule") {
addMsg("user", "Schedule an appointment");
addMsg("bot", "Opening the appointments page...");
setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/appointments"); setOpen(false); resetStep(); }, 600);
} else if (option === "claims") {
addMsg("user", "View claims");
addMsg("bot", "Opening the claims page...");
setTimeout(() => { setLocation("/claims"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/claims"); setOpen(false); resetStep(); }, 600);
} else if (option === "eligibility") {
addMsg("user", "Check Eligibility");
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
@@ -199,7 +232,7 @@ export function ChatbotButton() {
autoCheck: getAutoCheck(eligibilityData.dobISO),
}));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const handleEligibilityFromPatient = () => {
@@ -214,13 +247,13 @@ export function ChatbotButton() {
}));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
}
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const prefillAndNavigate = (memberId: string, dobISO: string, autoCheck: string) => {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
};
const handleEligibilityIdRun = () => {
@@ -257,13 +290,17 @@ export function ChatbotButton() {
setStep("ai-loading");
try {
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text });
const history = messages
.filter((m) => !m.isLoading)
.slice(-15)
.map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text, history });
const data = await res.json();
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
if (data.action === "navigate" && data.actionData?.url) {
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800);
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); resetStep(); }, 800);
return;
}
@@ -313,6 +350,41 @@ export function ChatbotButton() {
return;
}
if (data.action === "appointment_created") {
setStep("menu");
return;
}
if (data.action === "need_appointment_selection" && data.actionData) {
setApptSelectionData({
patient: data.actionData.patient,
siteKey: data.actionData.siteKey,
matchedCodes: data.actionData.matchedCodes ?? [],
options: data.actionData.options ?? [],
});
setStep("need-appointment-selection");
return;
}
if (data.action === "claim_only_ready" && data.actionData) {
const { patient, matchedCodes, siteKey, serviceDate, appointmentId } = data.actionData;
if (patient?.id && matchedCodes?.length > 0) {
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({ codes: matchedCodes, siteKey, serviceDate, autoSubmit: true })
);
}
const url = appointmentId
? `/claims?appointmentId=${appointmentId}`
: `/claims?newPatient=${patient?.id}`;
setTimeout(() => {
setLocation(url);
setOpen(false);
resetStep();
}, 600);
return;
}
setStep("menu");
} catch {
replaceLastMsg("Sorry, something went wrong. Please try again.");
@@ -333,7 +405,8 @@ export function ChatbotButton() {
step === "patient-found" ||
step === "eligibility-id-ready" ||
step === "check-and-claim-ready" ||
step === "need-insurance-clarification";
step === "need-insurance-clarification" ||
step === "need-appointment-selection";
return (
<>
@@ -360,13 +433,24 @@ export function ChatbotButton() {
<Bot className="h-4 w-4" />
<span className="font-semibold text-sm">Assistant</span>
</div>
<button
onClick={handleClose}
className="hover:opacity-70 transition-opacity rounded"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={reset}
className="flex items-center gap-1 text-xs hover:opacity-70 transition-opacity rounded px-1.5 py-0.5 border border-white/30 hover:bg-white/10"
title="Clear chat history"
>
<RotateCcw className="h-3 w-3" />
Clear
</button>
<button
onClick={handleClose}
className="hover:opacity-70 transition-opacity rounded"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Messages */}
@@ -631,6 +715,44 @@ export function ChatbotButton() {
</div>
)}
{/* Appointment selection */}
{step === "need-appointment-selection" && apptSelectionData && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 space-y-2">
<p className="text-xs font-semibold text-amber-800">Which appointment date?</p>
<div className="flex flex-col gap-1.5 pt-1">
{apptSelectionData.options.map((opt) => (
<button
key={opt.serviceDate}
className="text-left text-xs px-3 py-2 rounded-lg border border-amber-300 hover:bg-amber-100 transition-colors font-medium"
onClick={() => {
addMsg("user", opt.label);
addMsg("bot", `Using ${opt.label} as the service date.`);
sessionStorage.setItem(
"chatbot_claim_prefill",
JSON.stringify({
codes: apptSelectionData.matchedCodes,
siteKey: apptSelectionData.siteKey,
serviceDate: opt.serviceDate,
autoSubmit: true,
})
);
setTimeout(() => {
setLocation(`/claims?appointmentId=${opt.appointmentId}`);
setOpen(false);
resetStep();
}, 600);
}}
>
{opt.label}
</button>
))}
</div>
<Button size="sm" variant="ghost" className="h-7 text-xs w-full" onClick={resetStep}>
Cancel
</Button>
</div>
)}
<div ref={messagesEndRef} />
</div>

View File

@@ -117,6 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logoutMutation = useMutation({
mutationFn: async () => {
localStorage.removeItem("token");
sessionStorage.removeItem("chatbot_messages");
await apiRequest("POST", "/api/auth/logout");
},
onSuccess: () => {

View File

@@ -38,9 +38,8 @@ import { useLicense } from "@/hooks/use-license";
export default function ClaimsPage() {
const [isClaimFormOpen, setIsClaimFormOpen] = useState(false);
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(
null
);
const [selectedPatientId, setSelectedPatientId] = useState<number | null>(null);
const [chatbotAutoSubmitSiteKey, setChatbotAutoSubmitSiteKey] = useState<string | undefined>(undefined);
// for redirect from appointment page directly, then passing to claimform
const [selectedAppointmentId, setSelectedAppointmentId] = useState<
number | null
@@ -239,6 +238,17 @@ export default function ClaimsPage() {
const id = Number(newPatient);
if (!Number.isFinite(id) || id <= 0) return;
// Check if chatbot requested auto-submit with a specific insurance
try {
const raw = sessionStorage.getItem("chatbot_claim_prefill");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed?.autoSubmit && parsed?.siteKey) {
setChatbotAutoSubmitSiteKey(parsed.siteKey);
}
}
} catch {}
handleNewClaim(id);
clearUrlParams(["newPatient"]);
}, [newPatient]);
@@ -786,6 +796,7 @@ export default function ClaimsPage() {
const closeClaim = () => {
setSelectedPatientId(null);
setSelectedAppointmentId(null);
setChatbotAutoSubmitSiteKey(undefined);
setIsClaimFormOpen(false);
clearUrlParams(["newPatient", "appointmentId"]);
@@ -904,7 +915,8 @@ export default function ClaimsPage() {
<ClaimForm
patientId={selectedPatientId}
appointmentId={selectedAppointmentId ?? undefined}
autoSubmit={mode === "direct"}
autoSubmit={mode === "direct" || !!chatbotAutoSubmitSiteKey}
autoSubmitSiteKey={chatbotAutoSubmitSiteKey}
proceduresOnly={mode === "procedures"}
onClose={closeClaim}
onSubmit={handleClaimSubmit}

View File

@@ -179,8 +179,14 @@ export default function InsuranceStatusPage() {
try {
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
if (id) setMemberId(id);
if (dob) setDateOfBirth(parseLocalDate(dob));
if (ac === "mh" || ac === "cmsp") pendingAutoCheck.current = ac;
if (dob) {
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
: dob;
setDateOfBirth(parseLocalDate(normalized));
}
if (ac) pendingAutoCheck.current = ac;
sessionStorage.removeItem("chatbot_eligibility");
} catch {}
};

View File

@@ -210,7 +210,23 @@ class AutomationDentaQuestEligibilityCheck:
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@type='password']")))
password_field.clear()
password_field.send_keys(self.dentaquest_password)
# Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login
try:
remember_me = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH,
"//label[.//span[contains(text(),'Remember me')] or "
"contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | "
"//input[@type='checkbox' and ("
"contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or "
"contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]"
))
)
remember_me.click()
print("[DentaQuest login] Checked 'Remember me'")
except Exception:
print("[DentaQuest login] No 'Remember me' found on login page (continuing)")
# Click login button
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit']")))
login_button.click()

View File

@@ -187,6 +187,22 @@ class AutomationTuftsSCOClaimSubmit:
password_field.send_keys(self.dentaquest_password)
print("[TuftsSCO Claim login] Entered password")
# Check "Remember me" before signing in — mirrors DDMA to avoid OTP on next login
try:
remember_me = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH,
"//label[.//span[contains(text(),'Remember me')] or "
"contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember me')] | "
"//input[@type='checkbox' and ("
"contains(translate(@name,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember') or "
"contains(translate(@id,'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'remember'))]"
))
)
remember_me.click()
print("[TuftsSCO Claim login] Checked 'Remember me'")
except Exception:
print("[TuftsSCO Claim login] No 'Remember me' found on login page (continuing)")
signin_btn = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH,
"//button[@type='submit'] | //input[@type='submit'] | "