feat: Preauth column for AI claims; searchable policy holder + ADA box 18; 2PA fix
- Schedule: last column is the Preauth column; "Claim for Column with AI" is now "Claim/Preauth for Column with AI" — that column's appointments go to PreAuth (MH, CCA, Tufts SCO, United/DentalHub; other insurances are skipped), the rest to Claim. Preauthorized appointments (hasPreauth) aren't resubmitted - PreAuth payloads carry appointmentId; appointment X-rays/attachments are sent with MH/CCA/UnitedDH/Tufts preauths (shared appendAppointmentFiles helper) - Patient form: policy holder is searchable from patients in the DB, plus "Relationship to Policy Holder/Subscriber" (Self, Spouse, Dependent Child, Other). New Patient columns policyHolderPatientId, policyHolderRelationship; browser-side schema updated so the fields aren't stripped on save - ADA form: box 12-14 filled from the linked policy holder's record; box 18 ticks the relationship (Self when the patient is the holder) - AI notes: a bare "2PA" is an image count, not tooth #2 — restored before CDT lookup so it maps to D0220 #8 / D0230 #24 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,8 @@ Rules:
|
||||
e.g. "3 PA (#3, 14, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #30"]
|
||||
e.g. "4 PA (#3, 14, 19, 30)" → ["1 pa, #3", "2nd pa, #14", "2nd pa, #19", "2nd pa, #30"]
|
||||
e.g. "2 pa #3 #14" → ["1 pa, #3", "2nd pa, #14"]
|
||||
- A number written right before PA with NO tooth numbers ("2PA", "4 PA") is the image count, never a tooth number — keep it exactly as written:
|
||||
e.g. "perio exam, 2PA, 4BW" → ["perio exam", "2PA", "4BW"] (NOT "1 pa, #2")
|
||||
- insuranceHint is only set when the user explicitly names an insurance in the message
|
||||
- renderingProvider is only set when the user explicitly names a treating/rendering provider or doctor
|
||||
e.g. "with provider Kai Gao", "provider Dr. Smith", "rendered by Kai Gao", "doctor Kai Gao"
|
||||
@@ -239,6 +241,19 @@ Rules:
|
||||
// nothing else in it — e.g. "100232102531 4/27/1999" or "100232102531, 4/27/1999".
|
||||
// Bypasses the LLM entirely so this always resolves to eligibility_by_id, since some
|
||||
// providers hedge/refuse structured output on a bare digit string that looks like an SSN.
|
||||
// "2PA" / "4 PA" with no tooth numbers is an image count. The LLM sometimes rewrites it as
|
||||
// "1 pa, #2" (count read as a tooth), so put the original "NPA" back for the CDT lookup.
|
||||
function restoreBarePaCount(message: string, names: string[]): string[] {
|
||||
const bare = [...message.matchAll(/\b(\d+)\s*pas?\b(?!\s*[(#])/gi)];
|
||||
if (bare.length !== 1) return names;
|
||||
const isPa = (n: string) => /^\s*(?:\d+(?:st|nd|rd|th)?\s*)?(?:pa|periapical)\b/i.test(n);
|
||||
const first = names.findIndex(isPa);
|
||||
if (first === -1) return names;
|
||||
const kept = names.filter((n) => !isPa(n));
|
||||
kept.splice(first, 0, `${bare[0]![1]}PA`);
|
||||
return kept;
|
||||
}
|
||||
|
||||
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
|
||||
const text = message.trim();
|
||||
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
|
||||
@@ -331,6 +346,7 @@ export async function classifyInternalChat(
|
||||
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
|
||||
const parsed = JSON.parse(jsonStr) as ChatClassification;
|
||||
if (!parsed.intent || !parsed.fallbackReply) return fallback;
|
||||
if (parsed.procedureNames) parsed.procedureNames = restoreBarePaCount(message, parsed.procedureNames);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -102,16 +102,18 @@ router.get("/day", async (req: Request, res: Response): Promise<any> => {
|
||||
|
||||
// Enrich each appointment with procedure / claim status flags
|
||||
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
|
||||
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt] = await Promise.all([
|
||||
const [idsWithProcedures, idsWithClaimNumbers, procedureCodesByAppt, idsWithPreauth] = await Promise.all([
|
||||
storage.getAppointmentIdsWithProcedures(appointmentIds),
|
||||
storage.getAppointmentIdsWithClaimNumbers(appointmentIds),
|
||||
storage.getProcedureCodesByAppointmentIds(appointmentIds),
|
||||
storage.getAppointmentIdsWithPreauth(appointmentIds),
|
||||
]);
|
||||
|
||||
const enrichedAppointments = appointments.map((a) => ({
|
||||
...a,
|
||||
hasProcedures: a.id != null && idsWithProcedures.has(a.id),
|
||||
hasClaimWithNumber: a.id != null && idsWithClaimNumbers.has(a.id),
|
||||
hasPreauth: a.id != null && idsWithPreauth.has(a.id),
|
||||
procedureCodes: a.id != null ? (procedureCodesByAppt.get(a.id) ?? []) : [],
|
||||
}));
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranc
|
||||
import { formatDobForAgent } from "../utils/dateUtils";
|
||||
import { ADA_PAYERS, buildAdaClaimPdf } from "../services/adaClaimForm";
|
||||
import { backupScreenshots } from "../utils/screenshotBackup";
|
||||
import { appendAppointmentFiles } from "../utils/appointmentAttachments";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -347,24 +348,11 @@ router.post(
|
||||
mimetype: f.mimetype,
|
||||
}));
|
||||
|
||||
// Include attachments already saved on the appointment (e.g. via "Select Procedures")
|
||||
// that weren't re-uploaded in this request — read straight from disk by filePath.
|
||||
// Include attachments already saved on the appointment.
|
||||
// Skipped for chatbot-driven submissions, which already send every intended file and
|
||||
// shouldn't have unrelated older appointment attachments silently tacked on.
|
||||
if (claimData.appointmentId && !claimData.skipAppointmentAttachmentMerge) {
|
||||
const seenNames = new Set(filesForQueue.map((f) => f.originalname));
|
||||
const apptFiles = await storage.getAppointmentFiles(Number(claimData.appointmentId));
|
||||
for (const f of apptFiles) {
|
||||
if (!f.filePath || seenNames.has(f.filename)) continue;
|
||||
const absPath = path.join(process.cwd(), f.filePath);
|
||||
if (!fs.existsSync(absPath)) continue;
|
||||
filesForQueue.push({
|
||||
originalname: f.filename,
|
||||
bufferBase64: fs.readFileSync(absPath).toString("base64"),
|
||||
mimetype: f.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
seenNames.add(f.filename);
|
||||
}
|
||||
if (!claimData.skipAppointmentAttachmentMerge) {
|
||||
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
|
||||
}
|
||||
|
||||
const job = await seleniumQueue.add("claim-submit", {
|
||||
@@ -564,6 +552,9 @@ router.post(
|
||||
mimetype: f.mimetype,
|
||||
}));
|
||||
|
||||
// Include X-rays etc. already attached to the appointment
|
||||
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
|
||||
|
||||
const job = await seleniumQueue.add("claim-pre-auth", {
|
||||
jobType: "claim-pre-auth",
|
||||
userId: req.user.id,
|
||||
@@ -1263,9 +1254,14 @@ async function buildAdaFormForRequest(req: Request): Promise<AdaFormResult> {
|
||||
const treatingProvider =
|
||||
claim.npiProvider ?? (await storage.getNpiProvidersByUser(req.user!.id))[0] ?? null;
|
||||
|
||||
const holderId = (claim.patient as any).policyHolderPatientId as number | null | undefined;
|
||||
const policyHolderPatient =
|
||||
holderId && holderId !== claim.patient.id ? ((await storage.getPatient(holderId)) ?? null) : null;
|
||||
|
||||
const pdf = await buildAdaClaimPdf({
|
||||
claim,
|
||||
patient: claim.patient,
|
||||
policyHolderPatient,
|
||||
office,
|
||||
treatingProvider,
|
||||
payer,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
import multer from "multer";
|
||||
import { appendAppointmentFiles } from "../utils/appointmentAttachments";
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
@@ -55,6 +56,8 @@ router.post(
|
||||
bufferBase64: f.buffer.toString("base64"),
|
||||
mimetype: f.mimetype,
|
||||
}));
|
||||
// Include X-rays etc. already attached to the appointment
|
||||
await appendAppointmentFiles(filesForQueue, claimData.appointmentId);
|
||||
|
||||
const enrichedPayload = {
|
||||
claim: {
|
||||
|
||||
@@ -62,7 +62,19 @@ export type AdaClaimInput = {
|
||||
insuranceId?: string | null;
|
||||
groupNumber?: string | null;
|
||||
policyHolder?: string | null;
|
||||
policyHolderRelationship?: string | null;
|
||||
};
|
||||
/** Policy holder's own patient record (Patient.policyHolderPatientId), when not self */
|
||||
policyHolderPatient?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth?: Date | string | null;
|
||||
gender?: string | null;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zipCode?: string | null;
|
||||
} | null;
|
||||
office: {
|
||||
legalName?: string | null;
|
||||
officeName?: string | null;
|
||||
@@ -257,10 +269,12 @@ async function fillOnePage(
|
||||
const genderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[gender];
|
||||
|
||||
const holder = (patient.policyHolder ?? "").trim();
|
||||
const isSelf =
|
||||
!holder ||
|
||||
holder.toLowerCase() === "self" ||
|
||||
normName(holder) === normName(`${patient.firstName} ${patient.lastName}`);
|
||||
const relationship = (patient.policyHolderRelationship ?? "").trim().toUpperCase();
|
||||
const isSelf = relationship
|
||||
? relationship === "SELF"
|
||||
: !holder ||
|
||||
holder.toLowerCase() === "self" ||
|
||||
normName(holder) === normName(`${patient.firstName} ${patient.lastName}`);
|
||||
|
||||
if (isSelf) {
|
||||
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", patientName);
|
||||
@@ -268,11 +282,28 @@ async function fillOnePage(
|
||||
setText(form, "12 City, State, Zip", patientCsz);
|
||||
setText(form, "13 Date of Birth MMDDCCYY", dob);
|
||||
check(form, `14 ${genderBox}`);
|
||||
check(form, "18 Check Box1");
|
||||
} else if (input.policyHolderPatient) {
|
||||
// Subscriber is another patient in the system: fill 12-14 from their record
|
||||
const h = input.policyHolderPatient;
|
||||
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", `${h.lastName}, ${h.firstName}`);
|
||||
setText(form, "12 Address 1", h.address);
|
||||
setText(
|
||||
form,
|
||||
"12 City, State, Zip",
|
||||
cityStateZip(h.city, h.state || (h.address || h.city ? officeState : ""), h.zipCode),
|
||||
);
|
||||
setText(form, "13 Date of Birth MMDDCCYY", fmtDate(h.dateOfBirth));
|
||||
const hGenderBox = { M: "Check Box1", F: "Check Box2", U: "Check Box3" }[genderCode(h.gender)];
|
||||
check(form, `14 ${hGenderBox}`);
|
||||
} else {
|
||||
// Subscriber is someone else: name only; DOB, gender and relationship are completed by hand.
|
||||
// Subscriber typed by name only: DOB, gender and address are completed by hand.
|
||||
setText(form, "12 PolicyholderSubscriber Name Last First Middle Initial Suffix", holder);
|
||||
}
|
||||
// 18. Relationship to policyholder: Self, Spouse, Dependent Child, Other (left to right)
|
||||
const relationshipBox = isSelf
|
||||
? "Check Box1"
|
||||
: { SPOUSE: "Check Box2", DEPENDENT: "Check Box3", OTHER: "Check Box4" }[relationship];
|
||||
if (relationshipBox) check(form, `18 ${relationshipBox}`);
|
||||
setText(form, "15 Policyho derSubscriber ID Assigned by Plan", claim.memberId || patient.insuranceId);
|
||||
setText(form, "16 PlanGroup Number", patient.groupNumber);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface IStorage {
|
||||
updateClaimStatus(id: number, status: ClaimStatus): Promise<Claim>;
|
||||
deleteClaim(id: number): Promise<void>;
|
||||
getAppointmentIdsWithClaimNumbers(ids: number[]): Promise<Set<number>>;
|
||||
getAppointmentIdsWithPreauth(ids: number[]): Promise<Set<number>>;
|
||||
}
|
||||
|
||||
export const claimsStorage: IStorage = {
|
||||
@@ -137,4 +138,19 @@ export const claimsStorage: IStorage = {
|
||||
});
|
||||
return new Set(rows.map((r: { appointmentId: number | null }) => r.appointmentId));
|
||||
},
|
||||
|
||||
async getAppointmentIdsWithPreauth(ids: number[]): Promise<Set<number>> {
|
||||
if (!ids.length) return new Set();
|
||||
const rows = await db.claim.findMany({
|
||||
// PREAUTH records are created before the Selenium run, so only count ones that got a number back
|
||||
where: {
|
||||
appointmentId: { in: ids },
|
||||
status: "PREAUTH" as ClaimStatus,
|
||||
OR: [{ preAuthNumber: { not: null } }, { claimNumber: { not: null } }],
|
||||
},
|
||||
select: { appointmentId: true },
|
||||
distinct: ["appointmentId"],
|
||||
});
|
||||
return new Set(rows.map((r: { appointmentId: number | null }) => r.appointmentId));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { storage } from "../storage";
|
||||
|
||||
type QueuedFile = { originalname: string; bufferBase64: string; mimetype: string };
|
||||
|
||||
// Include attachments already saved on the appointment (e.g. X-rays added via "Select Procedures")
|
||||
// that weren't re-uploaded in this request — read straight from disk by filePath.
|
||||
export async function appendAppointmentFiles(
|
||||
filesForQueue: QueuedFile[],
|
||||
appointmentId: number | string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!appointmentId) return;
|
||||
const seenNames = new Set(filesForQueue.map((f) => f.originalname));
|
||||
const apptFiles = await storage.getAppointmentFiles(Number(appointmentId));
|
||||
for (const f of apptFiles) {
|
||||
if (!f.filePath || seenNames.has(f.filename)) continue;
|
||||
const absPath = path.join(process.cwd(), f.filePath);
|
||||
if (!fs.existsSync(absPath)) continue;
|
||||
filesForQueue.push({
|
||||
originalname: f.filename,
|
||||
bufferBase64: fs.readFileSync(absPath).toString("base64"),
|
||||
mimetype: f.mimeType ?? "application/octet-stream",
|
||||
});
|
||||
seenNames.add(f.filename);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user