From 44a1a41efa469bb72aed19a8ff32ebca6b15b6f1 Mon Sep 17 00:00:00 2001 From: Gitead Date: Sun, 11 Oct 2026 21:09:51 -0400 Subject: [PATCH] feat: Preauth column for AI claims; searchable policy holder + ADA box 18; 2PA fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/Backend/src/ai/internal-chat-graph.ts | 16 + apps/Backend/src/routes/appointments.ts | 4 +- apps/Backend/src/routes/claims.ts | 28 +- .../src/routes/insuranceStatusCCAPreAuth.ts | 3 + apps/Backend/src/services/adaClaimForm.ts | 43 +- apps/Backend/src/storage/claims-storage.ts | 16 + .../src/utils/appointmentAttachments.ts | 27 + .../src/components/claims/claim-form.tsx | 11 +- .../src/components/patients/patient-form.tsx | 67 +- .../src/components/patients/patient-table.tsx | 8 + .../patients/policy-holder-search.tsx | 116 +++ apps/Frontend/src/pages/appointments-page.tsx | 74 +- apps/Frontend/src/pages/claims-page.tsx | 13 + packages/db/generated/prisma/edge.js | 10 +- packages/db/generated/prisma/index-browser.js | 2 + packages/db/generated/prisma/index.d.ts | 292 ++++-- packages/db/generated/prisma/index.js | 10 +- packages/db/generated/prisma/package.json | 2 +- packages/db/generated/prisma/schema.prisma | 70 +- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + .../.prisma-zod-generator-manifest.json | 14 +- .../OfficeContactScalarFieldEnum.schema.js | 2 +- .../enums/PatientScalarFieldEnum.schema.d.ts | 2 +- .../PatientScalarFieldEnum.schema.d.ts.map | 2 +- .../enums/PatientScalarFieldEnum.schema.js | 2 +- .../enums/PatientScalarFieldEnum.schema.ts | 2 +- .../schemas/findFirstOfficeContact.schema.js | 2 + .../findFirstOrThrowOfficeContact.schema.js | 2 + .../findFirstOrThrowPatient.schema.d.ts | 12 +- .../findFirstOrThrowPatient.schema.d.ts.map | 2 +- .../schemas/findFirstOrThrowPatient.schema.js | 4 + .../schemas/findFirstOrThrowPatient.schema.ts | 4 + .../schemas/findFirstPatient.schema.d.ts | 12 +- .../schemas/findFirstPatient.schema.d.ts.map | 2 +- .../shared/schemas/findFirstPatient.schema.js | 4 + .../shared/schemas/findFirstPatient.schema.ts | 4 + .../schemas/findManyOfficeContact.schema.js | 2 + .../schemas/findManyPatient.schema.d.ts | 12 +- .../schemas/findManyPatient.schema.d.ts.map | 2 +- .../shared/schemas/findManyPatient.schema.js | 4 + .../shared/schemas/findManyPatient.schema.ts | 4 + .../shared/schemas/groupByPatient.schema.d.ts | 6 +- ...OfficeContactCountAggregateInput.schema.js | 1 + ...ontactCountOrderByAggregateInput.schema.js | 1 + .../OfficeContactCreateInput.schema.js | 1 + .../OfficeContactCreateManyInput.schema.js | 1 + ...iceContactCreateWithoutUserInput.schema.js | 1 + .../OfficeContactMaxAggregateInput.schema.js | 1 + ...eContactMaxOrderByAggregateInput.schema.js | 1 + .../OfficeContactMinAggregateInput.schema.js | 1 + ...eContactMinOrderByAggregateInput.schema.js | 1 + ...ntactOrderByWithAggregationInput.schema.js | 1 + ...eContactOrderByWithRelationInput.schema.js | 1 + ...ctScalarWhereWithAggregatesInput.schema.js | 1 + .../objects/OfficeContactSelect.schema.js | 1 + ...fficeContactUncheckedCreateInput.schema.js | 1 + ...tUncheckedCreateWithoutUserInput.schema.js | 1 + ...fficeContactUncheckedUpdateInput.schema.js | 1 + ...eContactUncheckedUpdateManyInput.schema.js | 1 + ...tUncheckedUpdateWithoutUserInput.schema.js | 1 + .../OfficeContactUpdateInput.schema.js | 1 + ...ceContactUpdateManyMutationInput.schema.js | 1 + ...iceContactUpdateWithoutUserInput.schema.js | 1 + .../objects/OfficeContactWhereInput.schema.js | 1 + .../PatientAvgAggregateInput.schema.js | 1 + .../PatientAvgAggregateInput.schema.ts | 1 + .../PatientAvgOrderByAggregateInput.schema.js | 1 + .../PatientAvgOrderByAggregateInput.schema.ts | 1 + .../PatientCountAggregateInput.schema.js | 2 + .../PatientCountAggregateInput.schema.ts | 2 + ...atientCountOrderByAggregateInput.schema.js | 2 + ...atientCountOrderByAggregateInput.schema.ts | 2 + .../objects/PatientCreateInput.schema.js | 2 + .../objects/PatientCreateInput.schema.ts | 2 + .../objects/PatientCreateManyInput.schema.js | 2 + .../objects/PatientCreateManyInput.schema.ts | 2 + .../PatientCreateManyUserInput.schema.js | 2 + .../PatientCreateManyUserInput.schema.ts | 2 + ...ntCreateWithoutAppointmentsInput.schema.js | 2 + ...ntCreateWithoutAppointmentsInput.schema.ts | 2 + .../PatientCreateWithoutClaimsInput.schema.js | 2 + .../PatientCreateWithoutClaimsInput.schema.ts | 2 + ...ntCreateWithoutCloudFoldersInput.schema.js | 2 + ...ntCreateWithoutCloudFoldersInput.schema.ts | 2 + ...CreateWithoutCommunicationsInput.schema.js | 2 + ...CreateWithoutCommunicationsInput.schema.ts | 2 + ...ntCreateWithoutConversationInput.schema.js | 2 + ...ntCreateWithoutConversationInput.schema.ts | 2 + ...tientCreateWithoutDocumentsInput.schema.js | 2 + ...tientCreateWithoutDocumentsInput.schema.ts | 2 + .../PatientCreateWithoutGroupsInput.schema.js | 2 + .../PatientCreateWithoutGroupsInput.schema.ts | 2 + ...PatientCreateWithoutPaymentInput.schema.js | 2 + ...PatientCreateWithoutPaymentInput.schema.ts | 2 + ...ientCreateWithoutProceduresInput.schema.js | 2 + ...ientCreateWithoutProceduresInput.schema.ts | 2 + .../PatientCreateWithoutUserInput.schema.js | 2 + .../PatientCreateWithoutUserInput.schema.ts | 2 + .../PatientMaxAggregateInput.schema.js | 2 + .../PatientMaxAggregateInput.schema.ts | 2 + .../PatientMaxOrderByAggregateInput.schema.js | 2 + .../PatientMaxOrderByAggregateInput.schema.ts | 2 + .../PatientMinAggregateInput.schema.js | 2 + .../PatientMinAggregateInput.schema.ts | 2 + .../PatientMinOrderByAggregateInput.schema.js | 2 + .../PatientMinOrderByAggregateInput.schema.ts | 2 + ...tientOrderByWithAggregationInput.schema.js | 2 + ...tientOrderByWithAggregationInput.schema.ts | 2 + .../PatientOrderByWithRelationInput.schema.js | 2 + .../PatientOrderByWithRelationInput.schema.ts | 2 + .../objects/PatientScalarWhereInput.schema.js | 3 + .../objects/PatientScalarWhereInput.schema.ts | 3 + ...ntScalarWhereWithAggregatesInput.schema.js | 3 + ...ntScalarWhereWithAggregatesInput.schema.ts | 3 + .../schemas/objects/PatientSelect.schema.js | 2 + .../schemas/objects/PatientSelect.schema.ts | 2 + .../PatientSumAggregateInput.schema.js | 1 + .../PatientSumAggregateInput.schema.ts | 1 + .../PatientSumOrderByAggregateInput.schema.js | 1 + .../PatientSumOrderByAggregateInput.schema.ts | 1 + .../PatientUncheckedCreateInput.schema.js | 2 + .../PatientUncheckedCreateInput.schema.ts | 2 + ...edCreateWithoutAppointmentsInput.schema.js | 2 + ...edCreateWithoutAppointmentsInput.schema.ts | 2 + ...ncheckedCreateWithoutClaimsInput.schema.js | 2 + ...ncheckedCreateWithoutClaimsInput.schema.ts | 2 + ...edCreateWithoutCloudFoldersInput.schema.js | 2 + ...edCreateWithoutCloudFoldersInput.schema.ts | 2 + ...CreateWithoutCommunicationsInput.schema.js | 2 + ...CreateWithoutCommunicationsInput.schema.ts | 2 + ...edCreateWithoutConversationInput.schema.js | 2 + ...edCreateWithoutConversationInput.schema.ts | 2 + ...eckedCreateWithoutDocumentsInput.schema.js | 2 + ...eckedCreateWithoutDocumentsInput.schema.ts | 2 + ...ncheckedCreateWithoutGroupsInput.schema.js | 2 + ...ncheckedCreateWithoutGroupsInput.schema.ts | 2 + ...checkedCreateWithoutPaymentInput.schema.js | 2 + ...checkedCreateWithoutPaymentInput.schema.ts | 2 + ...ckedCreateWithoutProceduresInput.schema.js | 2 + ...ckedCreateWithoutProceduresInput.schema.ts | 2 + ...tUncheckedCreateWithoutUserInput.schema.js | 2 + ...tUncheckedCreateWithoutUserInput.schema.ts | 2 + .../PatientUncheckedUpdateInput.schema.js | 3 + .../PatientUncheckedUpdateInput.schema.ts | 3 + .../PatientUncheckedUpdateManyInput.schema.js | 3 + .../PatientUncheckedUpdateManyInput.schema.ts | 3 + ...heckedUpdateManyWithoutUserInput.schema.js | 3 + ...heckedUpdateManyWithoutUserInput.schema.ts | 3 + ...edUpdateWithoutAppointmentsInput.schema.js | 3 + ...edUpdateWithoutAppointmentsInput.schema.ts | 3 + ...ncheckedUpdateWithoutClaimsInput.schema.js | 3 + ...ncheckedUpdateWithoutClaimsInput.schema.ts | 3 + ...edUpdateWithoutCloudFoldersInput.schema.js | 3 + ...edUpdateWithoutCloudFoldersInput.schema.ts | 3 + ...UpdateWithoutCommunicationsInput.schema.js | 3 + ...UpdateWithoutCommunicationsInput.schema.ts | 3 + ...edUpdateWithoutConversationInput.schema.js | 3 + ...edUpdateWithoutConversationInput.schema.ts | 3 + ...eckedUpdateWithoutDocumentsInput.schema.js | 3 + ...eckedUpdateWithoutDocumentsInput.schema.ts | 3 + ...ncheckedUpdateWithoutGroupsInput.schema.js | 3 + ...ncheckedUpdateWithoutGroupsInput.schema.ts | 3 + ...checkedUpdateWithoutPaymentInput.schema.js | 3 + ...checkedUpdateWithoutPaymentInput.schema.ts | 3 + ...ckedUpdateWithoutProceduresInput.schema.js | 3 + ...ckedUpdateWithoutProceduresInput.schema.ts | 3 + ...tUncheckedUpdateWithoutUserInput.schema.js | 3 + ...tUncheckedUpdateWithoutUserInput.schema.ts | 3 + .../objects/PatientUpdateInput.schema.js | 3 + .../objects/PatientUpdateInput.schema.ts | 3 + .../PatientUpdateManyMutationInput.schema.js | 3 + .../PatientUpdateManyMutationInput.schema.ts | 3 + ...ntUpdateWithoutAppointmentsInput.schema.js | 3 + ...ntUpdateWithoutAppointmentsInput.schema.ts | 3 + .../PatientUpdateWithoutClaimsInput.schema.js | 3 + .../PatientUpdateWithoutClaimsInput.schema.ts | 3 + ...ntUpdateWithoutCloudFoldersInput.schema.js | 3 + ...ntUpdateWithoutCloudFoldersInput.schema.ts | 3 + ...UpdateWithoutCommunicationsInput.schema.js | 3 + ...UpdateWithoutCommunicationsInput.schema.ts | 3 + ...ntUpdateWithoutConversationInput.schema.js | 3 + ...ntUpdateWithoutConversationInput.schema.ts | 3 + ...tientUpdateWithoutDocumentsInput.schema.js | 3 + ...tientUpdateWithoutDocumentsInput.schema.ts | 3 + .../PatientUpdateWithoutGroupsInput.schema.js | 3 + .../PatientUpdateWithoutGroupsInput.schema.ts | 3 + ...PatientUpdateWithoutPaymentInput.schema.js | 3 + ...PatientUpdateWithoutPaymentInput.schema.ts | 3 + ...ientUpdateWithoutProceduresInput.schema.js | 3 + ...ientUpdateWithoutProceduresInput.schema.ts | 3 + .../PatientUpdateWithoutUserInput.schema.js | 3 + .../PatientUpdateWithoutUserInput.schema.ts | 3 + .../objects/PatientWhereInput.schema.js | 3 + .../objects/PatientWhereInput.schema.ts | 3 + packages/db/shared/schemas/objects/index.js | 830 ++++++++++++++++++ .../OfficeContactAggregateResult.schema.js | 3 + .../OfficeContactCreateResult.schema.js | 1 + .../OfficeContactDeleteResult.schema.js | 1 + .../OfficeContactFindFirstResult.schema.js | 1 + .../OfficeContactFindManyResult.schema.js | 1 + .../OfficeContactFindUniqueResult.schema.js | 1 + .../OfficeContactGroupByResult.schema.js | 4 + .../OfficeContactUpdateResult.schema.js | 1 + .../OfficeContactUpsertResult.schema.js | 1 + .../PatientAggregateResult.schema.d.ts | 40 + .../PatientAggregateResult.schema.d.ts.map | 2 +- .../results/PatientAggregateResult.schema.js | 8 + .../results/PatientAggregateResult.schema.ts | 8 + .../results/PatientCreateResult.schema.d.ts | 6 + .../PatientCreateResult.schema.d.ts.map | 2 +- .../results/PatientCreateResult.schema.js | 2 + .../results/PatientCreateResult.schema.ts | 2 + .../results/PatientDeleteResult.schema.d.ts | 6 + .../PatientDeleteResult.schema.d.ts.map | 2 +- .../results/PatientDeleteResult.schema.js | 2 + .../results/PatientDeleteResult.schema.ts | 2 + .../PatientFindFirstResult.schema.d.ts | 6 + .../PatientFindFirstResult.schema.d.ts.map | 2 +- .../results/PatientFindFirstResult.schema.js | 2 + .../results/PatientFindFirstResult.schema.ts | 2 + .../results/PatientFindManyResult.schema.d.ts | 10 + .../PatientFindManyResult.schema.d.ts.map | 2 +- .../results/PatientFindManyResult.schema.js | 2 + .../results/PatientFindManyResult.schema.ts | 2 + .../PatientFindUniqueResult.schema.d.ts | 6 + .../PatientFindUniqueResult.schema.d.ts.map | 2 +- .../results/PatientFindUniqueResult.schema.js | 2 + .../results/PatientFindUniqueResult.schema.ts | 2 + .../results/PatientGroupByResult.schema.d.ts | 46 + .../PatientGroupByResult.schema.d.ts.map | 2 +- .../results/PatientGroupByResult.schema.js | 10 + .../results/PatientGroupByResult.schema.ts | 10 + .../results/PatientUpdateResult.schema.d.ts | 6 + .../PatientUpdateResult.schema.d.ts.map | 2 +- .../results/PatientUpdateResult.schema.js | 2 + .../results/PatientUpdateResult.schema.ts | 2 + .../results/PatientUpsertResult.schema.d.ts | 6 + .../PatientUpsertResult.schema.d.ts.map | 2 +- .../results/PatientUpsertResult.schema.js | 2 + .../results/PatientUpsertResult.schema.ts | 2 + .../variants/input/OfficeContact.input.js | 1 + .../schemas/variants/input/Patient.input.d.ts | 6 + .../variants/input/Patient.input.d.ts.map | 2 +- .../schemas/variants/input/Patient.input.js | 2 + .../schemas/variants/input/Patient.input.ts | 2 + .../variants/pure/OfficeContact.pure.js | 1 + .../schemas/variants/pure/Patient.pure.d.ts | 6 + .../variants/pure/Patient.pure.d.ts.map | 2 +- .../schemas/variants/pure/Patient.pure.js | 2 + .../schemas/variants/pure/Patient.pure.ts | 2 + .../variants/result/OfficeContact.result.js | 1 + .../variants/result/Patient.result.d.ts | 6 + .../variants/result/Patient.result.d.ts.map | 2 +- .../schemas/variants/result/Patient.result.js | 2 + .../schemas/variants/result/Patient.result.ts | 2 + packages/db/types/claim-types.d.ts | 1 + packages/db/types/claim-types.d.ts.map | 2 +- packages/db/types/claim-types.ts | 1 + packages/db/usedSchemas/browser.js | 2 + packages/db/usedSchemas/browser.ts | 2 + 261 files changed, 2159 insertions(+), 195 deletions(-) create mode 100644 apps/Backend/src/utils/appointmentAttachments.ts create mode 100644 apps/Frontend/src/components/patients/policy-holder-search.tsx create mode 100644 packages/db/prisma/migrations/20261011000000_add_patient_policy_holder_relationship/migration.sql diff --git a/apps/Backend/src/ai/internal-chat-graph.ts b/apps/Backend/src/ai/internal-chat-graph.ts index e0720dc9..a25d7b88 100644 --- a/apps/Backend/src/ai/internal-chat-graph.ts +++ b/apps/Backend/src/ai/internal-chat-graph.ts @@ -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); diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts index 95edd554..759201d9 100755 --- a/apps/Backend/src/routes/appointments.ts +++ b/apps/Backend/src/routes/appointments.ts @@ -102,16 +102,18 @@ router.get("/day", async (req: Request, res: Response): Promise => { // 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) ?? []) : [], })); diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 48a7ce3f..1da7fa9b 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -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 { 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, diff --git a/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts index ad5bd2b9..f92ab1ad 100644 --- a/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts +++ b/apps/Backend/src/routes/insuranceStatusCCAPreAuth.ts @@ -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: { diff --git a/apps/Backend/src/services/adaClaimForm.ts b/apps/Backend/src/services/adaClaimForm.ts index aad98175..ec1f0dd4 100644 --- a/apps/Backend/src/services/adaClaimForm.ts +++ b/apps/Backend/src/services/adaClaimForm.ts @@ -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); diff --git a/apps/Backend/src/storage/claims-storage.ts b/apps/Backend/src/storage/claims-storage.ts index 158a07d1..a064903c 100755 --- a/apps/Backend/src/storage/claims-storage.ts +++ b/apps/Backend/src/storage/claims-storage.ts @@ -24,6 +24,7 @@ export interface IStorage { updateClaimStatus(id: number, status: ClaimStatus): Promise; deleteClaim(id: number): Promise; getAppointmentIdsWithClaimNumbers(ids: number[]): Promise>; + getAppointmentIdsWithPreauth(ids: number[]): Promise>; } 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> { + 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)); + }, }; diff --git a/apps/Backend/src/utils/appointmentAttachments.ts b/apps/Backend/src/utils/appointmentAttachments.ts new file mode 100644 index 00000000..c0b648b1 --- /dev/null +++ b/apps/Backend/src/utils/appointmentAttachments.ts @@ -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 { + 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); + } +} diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 3210485d..1536b6b8 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -638,7 +638,8 @@ export function ClaimForm({ insuranceSiteKey, }); }); - setPrefillDone(true); + // With an appointment, 2b (procedures + X-ray attachments) marks prefill done + if (!appointmentId) setPrefillDone(true); } catch {} }, []); @@ -1118,6 +1119,7 @@ export function ClaimForm({ patientId: patientId, insuranceProvider: "Mass Health", insuranceSiteKey: "MH", + appointmentId: appointmentId ?? 0, }); // 5. Close form @@ -1607,6 +1609,7 @@ export function ClaimForm({ patientId, insuranceProvider: "CCA", insuranceSiteKey: "CCA", + appointmentId: appointmentId ?? 0, }); onClose(); @@ -1650,7 +1653,8 @@ export function ClaimForm({ patientId, insuranceProvider: "United/DentalHub", insuranceSiteKey: "UNITED_SCO", - claimFiles: udPreAuthFilesMeta, + appointmentId: appointmentId ?? 0, + claimFiles: mergeWithExistingAttachments(udPreAuthFilesMeta), }); onClose(); @@ -1694,7 +1698,8 @@ export function ClaimForm({ patientId, insuranceProvider: "Tufts SCO", insuranceSiteKey: "TUFTS_SCO", - claimFiles: preAuthFilesMeta, + appointmentId: appointmentId ?? 0, + claimFiles: mergeWithExistingAttachments(preAuthFilesMeta), }); onClose(); diff --git a/apps/Frontend/src/components/patients/patient-form.tsx b/apps/Frontend/src/components/patients/patient-form.tsx index 49fde95d..c36cd707 100755 --- a/apps/Frontend/src/components/patients/patient-form.tsx +++ b/apps/Frontend/src/components/patients/patient-form.tsx @@ -32,6 +32,15 @@ import { } from "@repo/db/types"; import { z } from "zod"; import { DateInputField } from "@/components/ui/dateInputField"; +import { PolicyHolderSearch } from "@/components/patients/policy-holder-search"; + +// ADA claim form box 18 — relationship to policyholder/subscriber +const POLICY_HOLDER_RELATIONSHIPS = [ + { value: "SELF", label: "Self" }, + { value: "SPOUSE", label: "Spouse" }, + { value: "DEPENDENT", label: "Dependent Child" }, + { value: "OTHER", label: "Other" }, +]; // New England first (most patients), then the remaining states alphabetically const US_STATES = [ @@ -53,6 +62,11 @@ interface PatientFormProps { onSubmit: (data: InsertPatient | UpdatePatient) => void; } +// Patients saved before the relationship field existed: no policy holder means the +// patient is the holder, so show Self (matches how the ADA form treats them). +const defaultRelationship = (p: Patient): string => + p.policyHolderRelationship || (!(p.policyHolder ?? "").trim() ? "SELF" : ""); + export type PatientFormRef = { submit: () => void; }; @@ -100,6 +114,7 @@ export const PatientForm = forwardRef( ? formatLocalDate(new Date(patient.dateOfBirth)) : null, insuranceProvider: normalizeInsuranceProvider(patient.insuranceProvider), + policyHolderRelationship: defaultRelationship(patient), }; } return { @@ -117,6 +132,8 @@ export const PatientForm = forwardRef( insuranceId: extractedInfo?.insuranceId || "", groupNumber: "", policyHolder: "", + policyHolderPatientId: null, + policyHolderRelationship: "SELF", allergies: "", medicalConditions: "", preferredLanguage: "English", @@ -156,6 +173,7 @@ export const PatientForm = forwardRef( ? formatLocalDate(new Date(patient.dateOfBirth)) : null, insuranceProvider: normalized, + policyHolderRelationship: defaultRelationship(patient), }; form.reset(resetValues); // Explicit setValue ensures the controlled Select re-renders with the new value @@ -532,6 +550,41 @@ export const PatientForm = forwardRef( )} /> + ( + + Relationship to Policy Holder/Subscriber + + + + )} + /> + ( Policy Holder (if not self) - + { + field.onChange(name); + form.setValue("policyHolderPatientId" as any, holderPatientId, { shouldDirty: true }); + // Picking someone else means the patient isn't the holder + if (name.trim() && form.getValues("policyHolderRelationship" as any) === "SELF") { + form.setValue("policyHolderRelationship" as any, "", { shouldDirty: true }); + } + }} + /> diff --git a/apps/Frontend/src/components/patients/patient-table.tsx b/apps/Frontend/src/components/patients/patient-table.tsx index 32ec5399..cb27af1c 100755 --- a/apps/Frontend/src/components/patients/patient-table.tsx +++ b/apps/Frontend/src/components/patients/patient-table.tsx @@ -1320,6 +1320,14 @@ export function PatientTable({ Policy Holder:{" "} {currentPatient.policyHolder || "Self"}

+ {currentPatient.policyHolderRelationship && ( +

+ Relationship:{" "} + {({ SELF: "Self", SPOUSE: "Spouse", DEPENDENT: "Dependent Child", OTHER: "Other" } as Record)[ + currentPatient.policyHolderRelationship + ] ?? currentPatient.policyHolderRelationship} +

+ )} diff --git a/apps/Frontend/src/components/patients/policy-holder-search.tsx b/apps/Frontend/src/components/patients/policy-holder-search.tsx new file mode 100644 index 00000000..55819587 --- /dev/null +++ b/apps/Frontend/src/components/patients/policy-holder-search.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import { X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { apiRequest } from "@/lib/queryClient"; +import { formatLocalDate } from "@/utils/dateUtils"; +import type { Patient } from "@repo/db/types"; + +interface PolicyHolderSearchProps { + /** Policy holder name as stored on the patient (free text) */ + value: string; + /** Linked patient record of the policy holder, if one was picked */ + holderPatientId: number | null; + /** Patient being edited — hidden from the results */ + excludePatientId?: number; + onChange: (name: string, holderPatientId: number | null) => void; +} + +// Type to search patients in the DB and pick the policy holder; free text is still allowed +// for holders who aren't patients here (then holderPatientId is null). +export function PolicyHolderSearch({ value, holderPatientId, excludePatientId, onChange }: PolicyHolderSearchProps) { + const [open, setOpen] = useState(false); + const [debounced] = useDebounce(value.trim(), 300); + const wrapperRef = useRef(null); + + // The search API matches one name field at a time, so query by the first word + // and narrow by the remaining words here ("john smith" → name=john, then must contain "smith"). + const tokens = debounced.toLowerCase().split(/[\s,]+/).filter(Boolean); + const { data: results = [], isFetching } = useQuery({ + queryKey: ["policy-holder-search", tokens[0] ?? ""], + queryFn: async () => { + const res = await apiRequest( + "GET", + `/api/patients/search?name=${encodeURIComponent(tokens[0]!)}&limit=50&offset=0`, + ); + if (!res.ok) return []; + const payload = await res.json(); + return Array.isArray(payload) ? payload : (payload.patients ?? []); + }, + enabled: open && tokens.length > 0 && holderPatientId == null, + }); + + const matches = results + .filter((p) => p.id !== excludePatientId) + .filter((p) => { + const full = `${p.firstName} ${p.lastName}`.toLowerCase(); + return tokens.every((t) => full.includes(t)); + }) + .slice(0, 10); + + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, []); + + return ( +
+ setOpen(true)} + onChange={(e) => { + // Editing the text unlinks any previously selected patient + onChange(e.target.value, null); + setOpen(true); + }} + className={holderPatientId != null ? "pr-8 border-blue-400 bg-blue-50" : "pr-8"} + /> + {value && ( + + )} + {holderPatientId != null && ( +

Linked to patient record #{holderPatientId}

+ )} + {open && holderPatientId == null && tokens.length > 0 && ( +
+ {isFetching && matches.length === 0 ? ( +
Searching…
+ ) : matches.length === 0 ? ( +
No matching patients — the name will be saved as typed.
+ ) : ( + matches.map((p) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index a167e746..6827c95a 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -100,6 +100,7 @@ interface ScheduledAppointment { patientInsuranceProvider: string | null; hasProcedures: boolean; hasClaimWithNumber: boolean; + hasPreauth: boolean; movedByAi?: boolean; staffId: number; date: string | Date; @@ -111,6 +112,17 @@ interface ScheduledAppointment { procedureCodes?: string[]; } +// Insurances with an online PreAuth flow on the claims page (others can't be preauthorized there). +// Maps either site-key spelling (AI "TUFTS_SCO" / local "TuftsSCO") to the key the claim form expects. +const PREAUTH_SITE_KEYS: Record = { + MH: "MH", + CCA: "CCA", + TUFTSSCO: "TUFTS_SCO", + UNITEDSCO: "UNITED_SCO", +}; +const toPreauthSiteKey = (siteKey: string): string | undefined => + PREAUTH_SITE_KEYS[siteKey.replace(/_/g, "").toUpperCase()]; + function deriveInsuranceSiteKey(provider: string | null | undefined): string { const p = (provider || "").toLowerCase().trim(); if (!p) return ""; @@ -384,6 +396,10 @@ export default function AppointmentsPage() { color: colors[index % colors.length] || "bg-gray-400", })); + // The last schedule column is the Preauth column: "Claim/Preauth for Column with AI" + // sends its appointments to PreAuth instead of Claim. + const preauthStaffId = staffMembers.length ? Number(staffMembers[staffMembers.length - 1]!.id) : null; + // Initialize column labels from localStorage; default A, B, C… for new staff useEffect(() => { if (!staffMembersRaw.length) return; @@ -703,6 +719,7 @@ export default function AppointmentsPage() { patientInsuranceProvider, hasProcedures: !!(apt as any).hasProcedures, hasClaimWithNumber: !!(apt as any).hasClaimWithNumber, + hasPreauth: !!(apt as any).hasPreauth, notes: (apt as any).notes ?? null, procedureCodes: (apt as any).procedureCodes ?? [], movedByAi: !!(apt as any).movedByAi, @@ -1654,10 +1671,12 @@ export default function AppointmentsPage() { const handleClaimForColumnWithAi = async () => { if (selectedClaimAiColumns.size === 0) return; const unclaimed = processedAppointments.filter( - (apt) => selectedClaimAiColumns.has(apt.staffId) && !apt.hasClaimWithNumber + (apt) => + selectedClaimAiColumns.has(apt.staffId) && + (apt.staffId === preauthStaffId ? !apt.hasPreauth : !apt.hasClaimWithNumber) ); if (!unclaimed.length) { - toast({ title: "No unclaimed appointments", description: "All appointments in the selected columns are already claimed." }); + toast({ title: "Nothing to submit", description: "All appointments in the selected columns are already claimed / preauthorized." }); return; } const autoRun = autoConfirmEnabled || autoClaimEnabled; @@ -1677,6 +1696,17 @@ export default function AppointmentsPage() { const handleAiClaimConfirm = () => { if (!aiClaimCurrentData) return; const { matchedCodes, siteKey, serviceDate, appointmentId } = aiClaimCurrentData; + const isPreauth = aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId; + const preauthSiteKey = toPreauthSiteKey(siteKey); + if (isPreauth && !preauthSiteKey) { + toast({ + title: "PreAuth not supported", + description: `${aiClaimCurrentData.patientName}: online PreAuth isn't available for ${siteKey || "this insurance"} — skipped.`, + variant: "destructive", + }); + handleAiClaimSkip(); + return; + } // Include current patient in queue; claims-page advances it only on successful submit. const fromCurrent = aiClaimQueue.slice(aiClaimCurrentIndex); if (fromCurrent.length > 0) { @@ -1689,14 +1719,24 @@ export default function AppointmentsPage() { } else { sessionStorage.removeItem("ai_claim_queue"); } - sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ - codes: matchedCodes, - siteKey, - serviceDate, - autoSubmit: true, - })); + if (isPreauth) { + sessionStorage.removeItem("chatbot_claim_prefill"); + sessionStorage.setItem("chatbot_preauth_prefill", JSON.stringify({ + codes: matchedCodes, + siteKey: preauthSiteKey, + serviceDate, + })); + } else { + sessionStorage.removeItem("chatbot_preauth_prefill"); + sessionStorage.setItem("chatbot_claim_prefill", JSON.stringify({ + codes: matchedCodes, + siteKey, + serviceDate, + autoSubmit: true, + })); + } // BCBS is a paper form filled and saved in the background — come straight back here - if (/^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1"); + if (!isPreauth && /^bcbs/i.test(siteKey)) sessionStorage.setItem("ai_claim_return_to_schedule", "1"); else sessionStorage.removeItem("ai_claim_return_to_schedule"); setAiClaimModalOpen(false); setLocation(`/claims?appointmentId=${appointmentId}`); @@ -1924,7 +1964,7 @@ export default function AppointmentsPage() { ) : ( <> - Claim for Column with AI + Claim/Preauth for Column with AI )} @@ -2375,8 +2415,14 @@ export default function AppointmentsPage() { {columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)} )} -
{staff.name}
-
{staff.role}
+ {Number(staff.id) === preauthStaffId ? ( +
Preauth
+ ) : ( + <> +
{staff.name}
+
{staff.role}
+ + )} ))} @@ -2516,7 +2562,7 @@ export default function AppointmentsPage() {

- AI Claim Queue + AI Claim/Preauth Queue

{aiClaimAutoRun && ( @@ -2626,7 +2672,7 @@ export default function AppointmentsPage() { onClick={handleAiClaimConfirm} > - Confirm & Claim + {aiClaimQueue[aiClaimCurrentIndex]?.staffId === preauthStaffId ? "Confirm & PreAuth" : "Confirm & Claim"} )}