- Add batch_eligibility, batch_claim, and batch_check_and_claim intents to AI classifier so multiple patients can be processed one by one - Add queue processing on insurance-status and claims pages to auto-start the next patient after each check/claim completes - Make patient schema firstName, lastName, phone optional so patients can be created with just member ID + DOB from eligibility checks - Cancel buttons now preserve chat history instead of clearing it - Patient-found card shows Check Eligibility, Eligibility & Appointment Today, and Cancel buttons - Claim service date asks user to pick between latest appointment and today when they differ - Login page subtitle styled with animated gradient Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
/**
|
|
* Shared utilities used by all job processors.
|
|
* Avoids duplicating helpers that currently live inside route files.
|
|
*/
|
|
import axios from "axios";
|
|
import PDFDocument from "pdfkit";
|
|
import fsSync from "fs";
|
|
import { storage } from "../../storage";
|
|
import {
|
|
InsertPatient,
|
|
insertPatientSchema,
|
|
} from "../../../../../packages/db/types/patient-types";
|
|
|
|
const SELENIUM_BASE_URL =
|
|
process.env.SELENIUM_AGENT_BASE_URL || "http://localhost:5002";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Python service helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Call a synchronous Python service endpoint and return its result.
|
|
* The Python service runs the selenium job inline and returns the result directly.
|
|
* Timeout is long (8 min) to accommodate slow selenium flows.
|
|
*/
|
|
export async function callPythonSync(
|
|
endpoint: string,
|
|
payload: any,
|
|
timeoutMs = 8 * 60 * 1000
|
|
): Promise<any> {
|
|
const resp = await axios.post(
|
|
`${SELENIUM_BASE_URL}${endpoint}`,
|
|
payload,
|
|
{ timeout: timeoutMs }
|
|
);
|
|
const data = resp.data;
|
|
if (data?.status === "error") {
|
|
const msg =
|
|
typeof data.message === "string"
|
|
? data.message
|
|
: data.message?.msg ?? "Selenium returned error status";
|
|
throw new Error(msg);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// General utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function sleep(ms: number) {
|
|
return new Promise<void>((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
export function splitName(fullName?: string | null) {
|
|
if (!fullName) return { firstName: "", lastName: "" };
|
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
|
const firstName = parts.shift() ?? "";
|
|
const lastName = parts.join(" ") ?? "";
|
|
return { firstName, lastName };
|
|
}
|
|
|
|
export async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
|
return new Promise<Buffer>((resolve, reject) => {
|
|
try {
|
|
const doc = new PDFDocument({ autoFirstPage: false });
|
|
const chunks: Uint8Array[] = [];
|
|
doc.on("data", (c: any) => chunks.push(c));
|
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
doc.on("error", reject);
|
|
|
|
const A4_W = 595.28;
|
|
const A4_H = 841.89;
|
|
doc.addPage({ size: [A4_W, A4_H] });
|
|
doc.image(imagePath, 0, 0, { fit: [A4_W, A4_H], align: "center", valign: "center" });
|
|
doc.end();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Patient DB helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createOrUpdatePatientByInsuranceId(options: {
|
|
insuranceId: string;
|
|
firstName?: string | null;
|
|
lastName?: string | null;
|
|
dob?: string | Date | null;
|
|
userId: number;
|
|
}) {
|
|
const { insuranceId, firstName, lastName, dob, userId } = options;
|
|
if (!insuranceId) throw new Error("Missing insuranceId");
|
|
|
|
// Normalize insuranceId the same way insertPatientSchema does (strip spaces)
|
|
const normalizedId = insuranceId.replace(/\s+/g, "");
|
|
|
|
const incomingFirst = (firstName || "").trim();
|
|
const incomingLast = (lastName || "").trim();
|
|
|
|
console.log(`[createOrUpdatePatient] insuranceId="${normalizedId}" firstName="${incomingFirst}" lastName="${incomingLast}" userId=${userId}`);
|
|
|
|
// Family members often share the same insuranceId (e.g. Delta Dental family plans).
|
|
// When a DOB is provided, look up by insuranceId+DOB so each family member is a
|
|
// distinct patient record. If the combo doesn't exist → new patient; never
|
|
// overwrite a different family member who happens to share the same insuranceId.
|
|
let dobDate: Date | null = null;
|
|
if (dob) {
|
|
const parsed = new Date(dob);
|
|
if (!isNaN(parsed.getTime())) dobDate = parsed;
|
|
}
|
|
|
|
let patient = null;
|
|
if (dobDate) {
|
|
// Primary lookup: exact insuranceId + DOB match
|
|
patient = await storage.getPatientByInsuranceIdAndDob(normalizedId, dobDate);
|
|
console.log(`[createOrUpdatePatient] id+DOB lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
|
if (!patient) {
|
|
// Fallback: patient exists but has no DOB yet — update rather than duplicate
|
|
const byIdOnly = await storage.getPatientByInsuranceId(normalizedId);
|
|
if (byIdOnly && !byIdOnly.dateOfBirth) {
|
|
patient = byIdOnly;
|
|
console.log(`[createOrUpdatePatient] id-only fallback (no DOB on record): found id=${patient.id}`);
|
|
}
|
|
}
|
|
} else {
|
|
// No DOB supplied — fall back to insuranceId-only (legacy / non-family plans)
|
|
patient = await storage.getPatientByInsuranceId(normalizedId);
|
|
console.log(`[createOrUpdatePatient] id-only lookup: ${patient ? `found id=${patient.id}` : "not found"}`);
|
|
}
|
|
|
|
if (patient && patient.id) {
|
|
const updates: any = {};
|
|
if (incomingFirst && String(patient.firstName ?? "").trim() !== incomingFirst)
|
|
updates.firstName = incomingFirst;
|
|
if (incomingLast && String(patient.lastName ?? "").trim() !== incomingLast)
|
|
updates.lastName = incomingLast;
|
|
if (dobDate && !patient.dateOfBirth) updates.dateOfBirth = dobDate;
|
|
if (Object.keys(updates).length > 0) {
|
|
console.log(`[createOrUpdatePatient] updating patient id=${patient.id} with`, updates);
|
|
await storage.updatePatient(patient.id, updates);
|
|
patient = dobDate
|
|
? await storage.getPatientByInsuranceIdAndDob(normalizedId, dobDate)
|
|
: await storage.getPatientByInsuranceId(normalizedId);
|
|
}
|
|
return patient;
|
|
}
|
|
|
|
const createPayload: any = {
|
|
firstName: incomingFirst,
|
|
lastName: incomingLast,
|
|
dateOfBirth: dobDate ?? dob, // use parsed Date; fallback to raw string if dobDate is null
|
|
gender: "",
|
|
phone: "",
|
|
userId,
|
|
insuranceId: normalizedId,
|
|
};
|
|
|
|
let patientData: InsertPatient;
|
|
try {
|
|
patientData = insertPatientSchema.parse(createPayload);
|
|
} catch (e1) {
|
|
console.warn(`[createOrUpdatePatient] schema parse failed:`, e1);
|
|
patientData = createPayload as InsertPatient;
|
|
}
|
|
|
|
try {
|
|
await storage.createPatient(patientData);
|
|
console.log(`[createOrUpdatePatient] patient created successfully for insuranceId="${normalizedId}"`);
|
|
} catch (dbErr: any) {
|
|
console.error(`[createOrUpdatePatient] DB create failed:`, dbErr?.message ?? dbErr);
|
|
throw dbErr;
|
|
}
|
|
|
|
return storage.getPatientByInsuranceId(normalizedId);
|
|
}
|