Compare commits

..
58 Commits
Author SHA1 Message Date
Gitead b899659e13 docs: add ngrok vs Cloudflare Tunnel comparison for Twilio tunneling 2026-09-02 22:59:25 -04:00
GiteadandClaude Sonnet 5 52ddfd9f57 feat: add Remaining/Third Party columns, toggle PDF viewer, retire CMSP as separate insurance type
- Add remainingBenefit and thirdPartyInsurance columns to Patient; extract both
  from the MassHealth accumulator/member-detail pages during the eligibility
  check and surface them in the eligibility table
- Add an updated-date display and a toggle to suppress the post-check PDF
  viewer popup (default off) on the insurance eligibility page
- Rename CMSP-prefixed PDF filenames and UI copy to MH, since CMSP and MH now
  share the same worker
- Fold CMSP into MassHealth/MH across patient/claim insurance-type dropdowns
  and the chatbot's eligibility routing, since they're the same insurance
- Remove auto-download of saved eligibility PDFs; downloading is now manual

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:32:43 -04:00
GiteadandClaude Sonnet 5 f8235787db fix: handle DDMA site redesign in eligibility check flow
DDMA now often skips the family-members results table and navigates
straight to the single-patient detail page, and labels the patient's
name "Subscriber name:" instead of "Name:". Update step1/step2 to
detect the detail-page URL correctly (it's nested under the search
path, so a plain substring check couldn't tell them apart), extract
the name from the new label, and fall back to parsing "Coverage is:"
for eligibility status when there's no results row to read it from.
Also fix a status-text substring bug ("active" matching inside
"inactive") and stop the backend from collapsing an unknown
eligibility result into INACTIVE. Skip name extraction entirely when
the caller already knows the patient (existing-patient recheck).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:20:38 -04:00
GiteadandClaude Sonnet 5 b860fd213f fix: allow chatbot auto-scheduling when eligibility status is UNKNOWN
Only block appointment creation on explicit INACTIVE status, since
scrapes for some plan types (e.g. MassHealth Child Medicaid) can land
on UNKNOWN without actually finding an ineligible record.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:39:57 -04:00
Gitead 116889dd4e perf: reduce fixed sleeps in MassHealth click_plus_button
Cuts unconditional delay when adding a service line from ~7s to ~3s
as a first pass at speeding up MassHealth claim submission.
2026-08-20 00:37:14 -04:00
GiteadandClaude Sonnet 5 ad9d311a99 fix: tolerate single-letter typos in CDT code text matching
Adds Levenshtein-based fuzzy word matching to lookupCdtCodes so
misspellings like "comprehensiv exam" resolve the same as
"comprehensive exam". Both the AI chat bot and the AI claim column
share this matcher, so the fix applies to both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 00:01:41 -04:00
GiteadandClaude Sonnet 5 374f96399e docs: add fallback keyring-disable steps for when apt purge isn't enough
D-Bus service activation can respawn gnome-keyring-daemon even after
the package is removed and PAM/autostart hooks are disabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:45:06 -04:00
GiteadandClaude Sonnet 5 e934d6bce5 chore: gitignore GCP service account key in PaymentOCRService
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:43:15 -04:00
GiteadandClaude Sonnet 5 304441d95a fix: nginx proxy_pass uses 127.0.0.1 instead of localhost
localhost resolves to ::1 first; backend/Vite only bind IPv4, causing
intermittent 502s when nginx tried the IPv6 loopback address.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:41:10 -04:00
GiteadandClaude Fable 5 5044fecb07 fix: UnitedDH selenium duplicated first attachment (multi-file input appends on send_keys)
DentalHub's upload input is a multi-file input; chromedriver send_keys
appends to the existing selection instead of replacing it, so the second
iteration re-uploaded the first file (2 attachments became 3). Clear the
input value before each send_keys and skip duplicate filePaths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 00:08:30 -04:00
GiteadandClaude Fable 5 efb85d0b92 feat: auto-confirm option for AI column claims (manual toggle + implied by Auto Claim)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 00:07:30 -04:00
ff 41bed21302 feat: send appointment confirmation SMS after AI-booked appointments
Voice-call bookings previously only spoke the confirmation during the
live call, leaving nothing for the patient afterward. Now every
successful booking (recall, reschedule, and new-patient) also sends a
standalone confirmation text, regardless of which channel the
conversation happened on.
2026-08-07 00:28:56 -04:00
ff 67e71a02e4 fix: AI recall follow-up message repeats "Great" and buries office hours
When the AI rejects a requested time in the recall booking flow, it reused
the same "Great, what time..." template used for a fresh ask, producing an
awkward double-greeting right after a rejection, and appended office hours
as a bare fragment instead of framing them as a sentence.
2026-08-07 00:07:10 -04:00
GiteadandClaude Fable 5 ff79cd1643 fix: earliest patient record wins when duplicates share a phone (inbound SMS/voice)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:11:55 -04:00
ffandClaude Sonnet 5 0cd095330a fix: AI recall-call flow, speech-to-text time parsing, and stale conversation state
- Reset conversation stage to a fresh start on every new voice call
  (recall and reminder alike), and on every hangup/timeout/goodbye,
  so an abandoned or completed call never leaves a patient stuck
  mid-flow for their next call or text.
- Add a staleness check to the SMS flow: after a long idle gap,
  check whether the patient's reply still reads as a continuation
  before resuming the stored stage, falling back to a friendly
  "it's been a while" re-greeting when unsure.
- Fix parseTime() to handle Twilio's speech-to-text output, which
  renders "pm"/"am" with periods and often drops the colon in a
  spoken time (e.g. "100 p.m." -> 1:00 PM) — this was silently
  failing and causing the AI to loop asking for a time it had
  already been given.
- Extend the voice Gather timeout so the AI doesn't hang up on a
  patient who just needs a few seconds to respond.
- Reword the recall-call opening line and rework the post-"yes"
  prompt to ask for day and time together.
- Fix rclone WebDAV server to serve only the backups folder instead
  of the entire backend app directory (was exposing .env and source).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:35:48 -04:00
ff 55ce52f276 fix: patient language field silently dropped by stale browser-side validation schema
packages/db/usedSchemas/browser.ts (the browser-resolved copy of the Patient
zod schema, kept separate from the Node copy since it can't import
Prisma-generated code) was missing several fields added to the real schema
over time — preferredLanguage, gender, insuranceProvider, insuranceId,
groupNumber, policyHolder, and the PLAN_NOT_ACCEPTED status. Since the schema
isn't strict, those fields were silently stripped from every patient
create/update in the browser with no validation error, so edits to them
never reached the server.

Also fixes the AI call greeting always playing in English regardless of the
patient's preferred language — the greeting text was a single hardcoded
string, unlike every later turn in the conversation which already branched
by language.
2026-08-05 21:40:53 -04:00
ffandClaude Sonnet 5 cef928be97 feat: add AI recall calling — books existing patients for routine cleanings
Adds a new recall conversation flow (recall-graph.ts) triggered from the
Patient Connection "AI Call" button: offers an existing patient a routine
exam & cleaning, classifies their yes/no reply, then books directly onto
the schedule (Column A, 30-minute slots, real conflict checking against
office hours and existing appointments) instead of only capturing a
preference for staff to follow up on.

Also adds a "Voice call" label in the message thread to distinguish call
transcripts from SMS, and updates the Call Templates settings UI with an
editable recall greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:13:09 -04:00
ffandClaude Sonnet 5 06dc63c53e feat: unify AI voice calls with SMS conversation rules, add Call Templates
AI phone calls now route through the same routePatientTurn() rule engine
as SMS (office-hours checks, slot-conflict checks, MassHealth eligibility,
staff escalation) instead of a freeform LLM chat, and speak in the
patient's preferred language (English/Spanish). Adds a separate "Call
Templates" settings section so spoken wording can be customized
independently from SMS wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 23:39:31 -04:00
ffandClaude Sonnet 5 089e94ba88 feat: add AI-driven phone calls to patient connection
Adds an "AI Call" option alongside Call/SMS/Chat that places an outbound
call where Lisa converses live with the patient via Twilio speech
gather + TTS, using the same conversational AI as chat. Includes a
separate, user-editable Call Template (Settings > AI Chat/Call Settings)
so the call greeting can be customized independently of SMS templates.

Also fixes the outbound-call webhook URL to always use the fixed public
Twilio hostname (CLOUDFLARE_HOST) instead of deriving it from the
triggering request, since staff-browser requests arrive over the
LAN-only hostname, which Twilio can never reach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 20:14:09 -04:00
ff ed5a8af835 Merge branch 'main' of https://gitea.mydentalofficemanagement.com/Gitead/DentalManagementMH07 2026-07-31 10:49:52 -04:00
ffandClaude Sonnet 5 9885a3d35b fix: decouple Python venv setup from npm install postinstall
The three Python services (PatientDataExtractorService, PaymentOCRService,
SeleniumService) provisioned their venvs via postinstall, chained into
root's own postinstall. A failure in any one of them aborted the whole
npm install, which could leave unrelated Node deps (e.g. Backend's sharp)
uninstalled — silently breaking the app with no indication why.

Renamed each service's postinstall to an explicit "setup" script, added a
root "npm run setup:python" to run all three, and documented it as its
own step in the README instead of an automatic install-time side effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 10:49:06 -04:00
Gitead 75538126f2 docs: note server_names_hash_bucket_size fix for long local-<office> hostnames
Community Dentists of Lowell hit this during LAN HTTPS setup; documenting
it in Step 6 saves the next office from rediscovering it.
2026-07-31 10:11:36 -04:00
Gitead 55d798b45f docs: add Google Cloud Vision setup section to root README
Fresh-machine setup guide didn't cover the Payment OCR Service's Google
Vision credentials — add it as its own section (enable API, create
service-account key, install as google_credentials.json, verify).
2026-07-30 23:52:37 -04:00
Gitead 9efa8f6e0a docs+perf: document Google Vision key setup, consolidate OCR calls per screenshot
- README: step-by-step Google Cloud Vision API key creation and setup
  (enable API, create service-account key, place as google_credentials.json)
- ocrLocate: split into getOcrWords()/matchWordsForText() so a screenshot is
  OCR'd once and matched locally against multiple text targets, instead of a
  separate billed Vision API call per target (patient-row and Exam steps each
  cut from 2-3 calls down to 1)
2026-07-30 23:27:33 -04:00
Gitead 5cdd3452f2 fix: use Google Vision OCR instead of AI-vision estimates for Windows Type Agent clicks
Claude vision's self-reported text coordinates had a small but consistent bias,
causing the patient-row double-click to land above the name and the Exam-column
divider count to occasionally miscount and hard-fail. Swap patient-row, Exam,
and Save button targeting to exact OCR bounding boxes (Google Vision via the
existing PaymentOCRService) instead of AI-estimated ratios.
2026-07-30 23:14:02 -04:00
GiteadandClaude Sonnet 5 3f83794ac5 fix: add missing migration for auto-MH-check fields and drop dead lab_rx_template column
schema.prisma had autoMhCheckEnabled/DayOfWeek/Hour on User (added in
fc3e8c0e) with no corresponding migration, so migrate deploy on a fresh
DB left those columns missing and broke seeding. Also drops
lab_rx_template.doctorName, a column that existed only in the DB (added
by an old migration) but was never in schema.prisma or referenced by
any app code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:38:11 -04:00
Gitead 76bd99f7ff fix: Tufts SCO claim file attach — send_keys directly, skip button click
Clicking "Add a file" triggers input.click() which opens the OS-native
file picker that WebDriver can't control, causing send_keys on the
underlying input to fail with a message-less exception. The input is
already present in the DOM (display:none) so send_keys can target it
directly without the click.
2026-07-29 23:57:39 -04:00
Gitead 44c6d2f833 fix: Tufts SCO claim combobox selects matching option instead of first
_fill_combobox typed the value then used a single unioned XPath
(match-by-text | option[1]) to pick the dropdown option. Selenium's
find_element on a multi-match XPath returns the first node in
document order, not the first union alternative, so option[1] (e.g.
tooth "1") was always clicked regardless of what was typed (e.g. "22").
Split into a value-match wait with a separate fallback wait for
option[1] only when no match is found.
2026-07-29 23:19:18 -04:00
GiteadandClaude Sonnet 5 6d0838446b fix: cascade-delete stale ClaimFile rows and sync claim attachments
Deleting/replacing an appointment attachment left an orphaned ClaimFile
copy behind (auto-linked earlier by autoLinkAppointmentFiles), causing
the Selenium claim automation to upload extra files beyond what's
currently attached to the appointment. Claim PUT was also append-only
for claimFiles, so removing an attachment in the UI never persisted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:57:21 -04:00
GiteadandClaude Sonnet 5 65dc795eb2 fix: locate patient row by real text position, drop unreliable AI row-verify
Row selection previously computed the target row's y arithmetically
(header.y + rowHeight * rowIndex), which drifted further off-row the
deeper the target patient was in the filtered list, and an AI
"onTarget" verification step added to catch drift instead confirmed a
click point that was visibly in blank space below the grid.

locateAllOnScreenshot now reports each text match's top/bottom edges
instead of a self-estimated center, and the patient row step locates
the target's first and last name directly, pairing whichever
occurrences land on the same row instead of trusting arithmetic or a
qualitative AI judgment call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 21:53:12 -04:00
GiteadandClaude Sonnet 5 ad2e15ec62 feat: window-diff-based Type Agent locating for Open Dental appointment flow
Replaces fragile whole-screen text matching with a screenshot-diff step that
finds each dialog's actual pixel bounds, then restricts every subsequent AI
locate/click to that cropped region — eliminating false matches from text
elsewhere on screen (title bars, side panels). Adds column-boundary and
row-height detection so the patient row and Exam procedure click positions
are computed geometrically instead of relying on repeated fuzzy AI guesses
for visually similar neighbors. Also adds per-run screenshot/debug-crop
backups and a structured run.log for diagnosing failed runs, plus a
cmd:move primitive on the Windows agent for pre-click confirmation crops.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 08:50:47 -04:00
GiteadandClaude Sonnet 5 59f064583a feat: AI Type Agent - vision-based Open Dental automation via Windows agent
Adds a Windows-side agent (screenshot/click/type over HTTP) plus backend
services to locate UI elements via vision and drive an existing-patient
appointment flow in Open Dental, wired into the Copy/Type Agent page and
socket progress updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:24:27 -04:00
Gitead ae961781ac fix: CCA preauth status stays Submitted (not Approved) and screenshot mime type
- ccaPreAuthProcessor: keep claim status as PREAUTH after successful
  submission instead of jumping straight to APPROVED, matching the
  Tufts SCO/UnitedDH/MassHealth preauth flows; also tag PNG fallback
  screenshots with image/png instead of the default application/pdf
  so they render in the Documents preview modal.
- claims-recent-table: add a PREAUTH status case so it displays as
  "Submitted" instead of falling through to the raw enum value.
2026-07-25 10:15:35 -04:00
Gitead f457d6282a fix: CCA claim submission grid targeting, retry, and dashboard claim-number lookup
- Fix NameError (col_idx vs col_expr) that silently corrupted grid-fill logs
- Poll for new grid rows to render before clicking, instead of failing immediately
- Abort submission with a clear error if code/tooth/billed amount fail to fill,
  instead of silently submitting an incomplete claim
- Compute grid column positions live from header metadata instead of stale
  hardcoded indices; billed amount was landing on a hidden helper column
  (TAB_FROM_CODE_CELL) via td[last()] instead of the real Billed Amt column
- Read claim/encounter number from the top (newest) dashboard row instead
  of the bottom row
2026-07-25 09:49:53 -04:00
GiteadandClaude Sonnet 5 d5cecb0b54 fix: only skip appointment-attachment merge when automated claim already has files
skipAppointmentAttachmentMerge was blanket-set for any autoSubmit path
(chatbot, Claim with AI batch automation, insurance-status auto-check),
so screenshots/files attached directly on the appointment were silently
dropped from the MassHealth Selenium upload whenever a claim was
auto-submitted. Now the merge is only skipped if the automated flow
actually supplied its own files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 00:29:26 -04:00
Gitead 870bda5950 feat: chatbot screenshot-only eligibility detect, Sun Life/DentaQuest auto-check, chatbot attachment handoff to preauth; fix TuftsSCO preauth Selenium reliability
- Chatbot: submitting with only a screenshot attached (no text) now triggers the same
  "AI Detect & Check Eligibility" flow as the Copy Agent page
- detect-eligibility-info now also extracts the visible insurance payer name and picks
  Tufts SCO auto-check when both Sun Life and DentaQuest are detected, else MassHealth
- Chatbot-attached files are now handed off to both claim and preauth forms unconditionally,
  not just claims
- TuftsSCO preauth Selenium worker: verify-and-retry the Tooth field (typing was silently
  getting reset by duplicate-procedure-code warning banners), add a final re-verification
  pass across all rows, fix acknowledgement-checkbox targeting/verification, and verify
  the "Next step" click actually advances the wizard instead of trusting a blind click
2026-07-25 00:10:05 -04:00
GiteadandClaude Sonnet 5 6b9b3c41f2 fix: skip appointment-file merge for chatbot claims; add live screenshot capture to chatbot
- selenium-claim route no longer re-appends previously saved appointment
  attachments when the chatbot's autoSubmit flow already sent every intended
  file, preventing an unwanted extra upload (e.g. attach 2 -> Selenium sees 3).
- Add a "Take screenshot" button to the AI chatbot's attachment bar, reusing
  the same capture/crop flow as the appointment editor, so claims and
  preauths can be submitted from the chatbot with a live screenshot attached.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:31:34 -04:00
GiteadandClaude Sonnet 5 a42ef5f570 fix: persist and correctly forward appointment attachments to insurance claims
- Add "surg ext" alias mapping to D7210 in the AI CDT lookup.
- Add GET/DELETE endpoints for appointment attachments, and load existing
  attachments in the appointment edit modal (they were uploaded correctly
  but never re-fetched, so they appeared to vanish on reopen).
- Fix claim-form.tsx so previously-saved appointment attachments are always
  loaded (even when a claim already exists) instead of only when creating a
  brand-new claim, preventing them from being silently wiped on save.
- Merge existing appointment attachments into every insurance submit handler
  (MassHealth, CCA, DDMA, UnitedDH, TuftsSCO) so Selenium submission always
  includes files saved earlier via the Schedule editor, not just files
  uploaded in the current claim-form session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:03:32 -04:00
ffandClaude Sonnet 5 b1932fa3d6 feat: add Provider field to appointment form, drive AI claim automation
Adds an optional Provider (NPI) select to the Edit Appointment form,
sourced from Settings > NPI Providers. The AI "Claim for Column" batch
flow now prefers the appointment's own provider when resolving which
provider Selenium should select on the insurance site (e.g. MassHealth
rendering provider dropdown), falling back to the existing per-procedure
selection, active claim, or first configured provider.

Also resyncs packages/db's committed compiled schema/client artifacts
with their .ts sources — they had drifted out of sync (some untouched
since July), which was silently stripping the new npiProviderId field
via stale .strict() zod schemas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:07:10 -04:00
Gitead ca568de817 fix: make chatbot patient-found DOB/Member ID editable and fix eligibility→appointment ordering
- Member ID and DOB in the AI chat "patient found" confirmation card are now
  editable inputs, and edits are persisted back to the patient record before
  proceeding so corrections don't revert to stale values.
- Fix auto-lookup-by-member-ID effect on the insurance status page clobbering
  a hand-edited DOB with the stale DB value during chatbot prefill.
- CMSP (under-14) eligibility flow was missing the appointment-creation hook
  that the adult MassHealth flow already had, so under-14 patients never got
  scheduled after a chatbot-initiated "eligibility & appointment" request.
- For already-known patients, the chatbot was creating the appointment before
  running the eligibility check instead of after; now eligibility is always
  checked first and the appointment created only afterward, and only when the
  resulting patient status is ACTIVE.
2026-07-20 20:05:13 -04:00
GiteadandClaude Sonnet 5 b30bed8903 fix: match PDF-imported patients by member ID when DOB is missing on record
MassHealth eligibility lookups via the AI chatbot only matched existing
patients on memberId+DOB, so patients imported from PDF extraction (which
have a member ID but no stored birthday) were treated as new/unknown even
though they already exist in the system.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:11:47 -04:00
GiteadandClaude Sonnet 5 8c65c3d625 docs: clarify RustDesk autostart opens a window, not just the service tray
The core service already auto-spawns its own tray icon per session, so
Step 18's autostart entry is specifically for opening the main window.
Also note that a permanent password avoids the OTP-refresh confusion
where the window briefly shows one password then updates to another.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:58:50 -04:00
GiteadandClaude Sonnet 5 27e5b73138 docs: add step to remove gnome-keyring after enabling autologin
Autologin skips password entry, so the login keyring never unlocks and
apps keep popping "Authentication required". Document removing it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 22:54:58 -04:00
Gitead 78ee0b92fe docs: add optional step for auto-starting Chrome and RustDesk on login 2026-07-19 22:26:23 -04:00
Gitead 6062b646ce docs: fix Chrome install step, apt-key is removed on Debian 13+ 2026-07-19 22:12:39 -04:00
Gitead 158634f8de docs: add optional RustDesk remote support step to setup guide 2026-07-19 18:32:58 -04:00
Gitead 75781b92a6 docs: add auto-login and git install steps to setup guide 2026-07-19 18:27:06 -04:00
GiteadandClaude Sonnet 5 e49dfdbee9 feat: add attachment upload to Edit Appointment form (disk, scanned, screenshot with crop)
Lets staff attach files to an appointment directly from the edit form via
local disk upload, picking a previously-saved scanned attachment, or an
inline screenshot with crop-before-save (matching the Copy Agent page's
crop UX), instead of detouring through the Copy Agent page. Also fixes a
latent data-loss bug in claim-form's save-for-appointment call, which
replaced all AppointmentFile rows instead of merging with existing ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 14:55:38 -04:00
Gitead f29064883c feat: fall back to saved procedures for no-notes appointments in AI claim queue, and include appointment attachments in MH selenium submission
- AI claim queue now uses previously saved AppointmentProcedure data (with a derived insurance site key) when an appointment has no notes but has saved procedures, instead of skipping with "No notes on this appointment"
- selenium-claim route now reads saved AppointmentFile attachments from disk when they weren't re-uploaded in the browser, so images/PDFs already attached via "Select Procedures" reach the MassHealth submission
2026-07-17 00:30:03 -04:00
Gitead 5979d27c57 feat: add Preauth Followup section to claims page
Adds From/To date pickers and CCA PA / United PA / Tufts PA / All recent PA
buttons above the Upload claim documents section; button logic is stubbed
for now.
2026-07-16 23:00:46 -04:00
GiteadandClaude Sonnet 5 95abbabb36 fix: patient selection in appointment form disappearing for non-recent patients
Selecting a patient from search results only stored the ID, not the
patient object. Closing the dropdown reset the search term and reverted
the list to "recent 50" patients, which often didn't include the just
selected patient, leaving nothing to render in the trigger. Also removed
a redundant prefillPatient reset that nulled the fallback display before
the list swap even happened.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 22:39:55 -04:00
GiteadandClaude Sonnet 5 6004bcb73d feat: editable insurance type selector on eligibility confirm card
Lets a user override the default MassHealth check on the chatbot's
eligibility-id-ready confirm card (e.g. switch to United SCO, CMSP,
DDMA, Delta Dental, Tufts SCO, or CCA) before running the check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 21:47:20 -04:00
GiteadandClaude Sonnet 5 c95d08c951 feat: AI-powered eligibility detection from Copy Agent screenshots
Adds an "AI Detect & Check Eligibility" action to the Copy Agent page that
sends captured screenshots to Claude to extract Member ID/DOB, then opens
the chatbot's eligibility confirm step pre-filled with the result.

- POST /api/ai/detect-eligibility-info: Claude vision extraction of
  Member ID/DOB from uploaded screenshots.
- POST /api/patients/create-temp: auto-creates a placeholder "PID-<id>"
  patient (matched by insuranceId+dob) when no patient is selected, so
  screenshots can be saved immediately; the real eligibility check later
  rewrites the patient's name and Cloud Storage folder automatically via
  the existing createOrUpdatePatientByInsuranceId matching logic.
- Chatbot: Member ID/DOB on the confirm card are now editable (MM/DD/YYYY),
  with edits synced back to the patient record before the check runs.
- Fixed a useEffect ordering bug on the insurance-status page that could
  silently clear a chatbot-prefilled Member ID/DOB before the auto-trigger
  effect ran, preventing the eligibility check from ever starting.
- Added a local-disk-only screenshot backup (apps/Backend/uploads/
  screenshot-backups/) for debugging, not exposed anywhere in the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 21:20:33 -04:00
GiteadandClaude Sonnet 5 34312b62bc feat: add "Choose from Server" attachment picker to claim/preauth forms and chatbot
Native file inputs can't be pointed at a default server folder for security
reasons, so instead the Browse buttons on the Claim/PreAuth/Select Procedures
forms and the AI chatbot's attach button now also offer picking from
previously saved Cloud Storage attachments (e.g. Copy Agent screenshots) via
a new server-side picker modal, scoped per-patient on the claim forms and
unscoped (recent across all patients) in the chatbot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 00:36:43 -04:00
ffandClaude Sonnet 5 8b245d00c6 docs: add troubleshooting for router DNS rebinding protection
Some routers (confirmed on Verizon Fios/G3100) block the local-<office>
hostname from resolving because it's a public domain pointing at a
private LAN IP, which matches the DNS rebinding attack pattern. Document
the router-level fix (DNS Rebind Protection exception) and the per-machine
hosts-file workaround for both the Debian server and Windows staff PCs,
including the antivirus hosts-file-lock gotcha on Windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:35:35 -04:00
Gitead c4c69bfd62 docs: document Twilio-only-scoped Cloudflare Tunnel variant
Adds the actual config used for Summit Dental Care, where the tunnel
points at nginx's port-80 Twilio-only block instead of the whole app,
so only /api/twilio/* is reachable from the internet.
2026-07-12 18:08:22 -04:00
Gitead 0d2fe63b9b docs: note disabling Apache before nginx install
We hit this exact conflict during setup (Apache pre-installed and
bound to port 80), so nginx failed to start until it was disabled.
2026-07-12 17:57:15 -04:00
Gitead 44dea49fd1 docs: add LAN HTTPS setup guide (nginx + certbot + Cloudflare DNS-01)
Documents the trusted-certificate setup used for local staff access,
needed because the Screen Capture API (Copy Agent screenshots) requires
a secure context.
2026-07-12 17:34:12 -04:00
1444 changed files with 29285 additions and 5752 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"is_task_list_visible": true, "is_task_list_visible": true,
"active_task": "backend#dev" "active_task": "frontend#dev"
} }
+477 -22
View File
@@ -40,14 +40,102 @@ sudo systemctl stop sshd && sudo systemctl disable sshd
Follow these steps in order after cloning the repository. Follow these steps in order after cloning the repository.
### Step 1 — Clone the repository ### Step 1 — Enable auto-login (LXQt / SDDM)
This office PC uses Debian with the LXQt desktop and the SDDM login manager. Set it to log straight into the desktop on boot, no password prompt.
Create a drop-in config (replace `jj` with the actual Linux username if different):
```sh
sudo mkdir -p /etc/sddm.conf.d
sudo tee /etc/sddm.conf.d/autologin.conf > /dev/null <<'EOF'
[Autologin]
User=jj
Session=lxqt.desktop
EOF
```
Verify it was written:
```sh
cat /etc/sddm.conf.d/autologin.conf
```
Reboot to apply — the PC should boot straight to the desktop with no login screen.
> If a different session is installed, list available sessions with `ls /usr/share/xsessions/` and use that filename instead of `lxqt.desktop`.
### Step 1a — Remove gnome-keyring (fixes "Authentication required" popup)
With autologin enabled, no password is ever typed at login, so `gnome-keyring` (the "Login" keyring) never gets unlocked automatically. This shows up as a recurring **"Authentication required — The login keyring did not get unlocked when you logged into your computer"** popup whenever an app (Chrome, NetworkManager, etc.) tries to read a stored password.
Since this PC doesn't need the keyring (no GNOME apps depending on it), remove it:
```sh
sudo apt-get purge -y gnome-keyring gnome-keyring-pkcs11 libpam-gnome-keyring
sudo apt-get autoremove -y
sudo sed -i '/pam_gnome_keyring\.so/d' /etc/pam.d/sddm /etc/pam.d/common-password
```
Reboot to confirm the popup no longer appears.
> Trade-off: Chrome saved passwords/cookie encryption falls back to its weaker "Basic" store, and NetworkManager Wi-Fi passwords may need to be re-entered once. If a future setup needs the keyring kept, unlock it silently instead by opening **Passwords and Keys** (`seahorse`) → right-click **Login** → **Change Password** → set both new-password fields blank.
#### If the popup (or a "choose a new password for keyring" prompt) still appears after a reboot
The `apt purge` above removes the package, but if something reinstalls `gnome-keyring`/`libpam-gnome-keyring` as a dependency later (e.g. installing another desktop app), it comes back. Deleting just the keyring files under `~/.local/share/keyrings/` doesn't fix it either — a new keyring gets created on the next login/app request and prompts for a password again.
Do this instead, which disables it at every level (PAM login hook, session autostart, and D-Bus on-demand activation) without needing to uninstall the package:
```sh
# 1. Disable PAM hooks (login + password-sync)
sudo sed -i \
-e 's/^-auth optional pam_gnome_keyring.so/#-auth optional pam_gnome_keyring.so/' \
-e 's/^-session optional pam_gnome_keyring.so auto_start/#-session optional pam_gnome_keyring.so auto_start/' \
/etc/pam.d/sddm
sudo sed -i 's/^password\toptional\tpam_gnome_keyring.so/#&/' /etc/pam.d/common-password
# 2. Stop the keyring daemon components from autostarting in the session
mkdir -p ~/.config/autostart
for f in gnome-keyring-pkcs11 gnome-keyring-secrets gnome-keyring-ssh; do
cp /etc/xdg/autostart/$f.desktop ~/.config/autostart/$f.desktop 2>/dev/null
echo "Hidden=true" >> ~/.config/autostart/$f.desktop
done
# 3. Mask D-Bus service activation (the part that keeps bringing it back —
# any app calling the Secret Service API, e.g. Chrome, spawns the daemon
# on demand even with PAM and autostart disabled)
sudo mkdir -p /usr/share/dbus-1/services-disabled
sudo mv /usr/share/dbus-1/services/org.freedesktop.secrets.service \
/usr/share/dbus-1/services/org.gnome.keyring.service \
/usr/share/dbus-1/services/org.freedesktop.impl.portal.Secret.service \
/usr/share/dbus-1/services-disabled/ 2>/dev/null
# 4. Kill the running daemon and clear existing keyring files
pkill -f gnome-keyring-daemon
rm -f ~/.local/share/keyrings/*
```
Reboot to confirm. Same trade-off as above: Chrome falls back to storing saved passwords unencrypted rather than via the Secret Service.
### Step 2 — Install Git
```sh
sudo apt update
sudo apt install -y git
# Verify
git --version
```
### Step 3 — Clone the repository
```sh ```sh
git clone <your-repo-url> git clone <your-repo-url>
cd DentalManagementMHAprilgg cd DentalManagementMHAprilgg
``` ```
### Step 2 — Install Node.js ### Step 4 — Install Node.js
Required to run the Backend and Frontend. Required to run the Backend and Frontend.
@@ -60,7 +148,7 @@ node -v # should print v20.x.x
npm -v npm -v
``` ```
### Step 3 — Install Python ### Step 5 — Install Python
Required to run the Selenium and OCR services. Required to run the Selenium and OCR services.
@@ -71,23 +159,23 @@ sudo apt-get install -y python3 python3-pip python3-venv
python3 --version # should print 3.10 or higher python3 --version # should print 3.10 or higher
``` ```
### Step 4 — Install Chrome ### Step 6 — Install Chrome
Required for the Selenium service to control a browser. Required for the Selenium service to control a browser.
```sh ```sh
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -O /tmp/google-chrome-stable.deb
echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list sudo apt install -y /tmp/google-chrome-stable.deb
sudo apt-get update
sudo apt-get install -y google-chrome-stable
# Verify # Verify
google-chrome --version google-chrome --version
``` ```
> `apt-key` is removed on modern Debian (13+), so the old signing-key + repo method no longer works. `apt install` on the downloaded `.deb` pulls in dependencies automatically.
> The `webdriver-manager` package (included in `requirements.txt`) automatically downloads the matching ChromeDriver — no manual driver setup needed. > The `webdriver-manager` package (included in `requirements.txt`) automatically downloads the matching ChromeDriver — no manual driver setup needed.
### Step 5 — Install PostgreSQL ### Step 7 — Install PostgreSQL
Primary database for the application. Primary database for the application.
@@ -125,7 +213,7 @@ psql -U postgres -d dentalapp -h 127.0.0.1 -W
> DATABASE_URL="postgresql://postgres:mypassword@localhost:5432/dentalapp" > DATABASE_URL="postgresql://postgres:mypassword@localhost:5432/dentalapp"
> ``` > ```
### Step 6 — Install Redis ### Step 8 — Install Redis
Used as the job queue for Selenium and OCR background tasks. Used as the job queue for Selenium and OCR background tasks.
@@ -138,7 +226,7 @@ sudo systemctl start redis-server
redis-cli ping # should print: PONG redis-cli ping # should print: PONG
``` ```
### Step 7 — Install rclone ### Step 9 — Install rclone
Required for cloud backup destinations (Google Drive, Dropbox, etc.). Required for cloud backup destinations (Google Drive, Dropbox, etc.).
@@ -153,19 +241,25 @@ To verify it installed correctly, open a terminal and run:
rclone version rclone version
``` ```
### Step 8 — Install Node.js dependencies ### Step 10 — Install Node.js dependencies
```sh ```sh
npm install npm install
``` ```
### Step 9 — Install Python dependencies ### Step 11 — Install Python dependencies
Python dependencies are installed automatically by `npm install` (Step 8) via each service's `postinstall` script. Each service creates its own `.venv` virtual environment — no manual pip commands needed. ```sh
npm run setup:python
```
> This approach is required on Debian 13+ where system-wide pip installs are blocked (PEP 668). This creates a `.venv` virtual environment inside each Python service (`PatientDataExtractorService`, `PaymentOCRService`, `SeleniumService`) and installs its `requirements.txt` into it — no manual pip commands needed.
### Step 10 — Set up environment variables > This venv approach is required on Debian 13+ where system-wide pip installs are blocked (PEP 668).
This is a separate, explicit step rather than an `npm install` `postinstall` hook on purpose: if Python/pip setup fails on a given machine (missing system Python, no network access, a pip resolution error), it won't also abort or corrupt the Node.js dependency install for the other apps. Re-run `npm run setup:python` any time you need to rebuild these virtual envs (e.g. after moving/renaming the project folder, since venvs bake in absolute paths).
### Step 12 — Set up environment variables
Copy the `.env.example` files and fill in the required values. Copy the `.env.example` files and fill in the required values.
@@ -173,9 +267,9 @@ Copy the `.env.example` files and fill in the required values.
npm run setup:env npm run setup:env
``` ```
### Step 10a — Set the Cloudflare subdomain for this office ### Step 12a — Set the Cloudflare subdomain for this office
After running `npm run setup:env` (Step 10), open the two `.env` files and fill in this office's Cloudflare subdomain. After running `npm run setup:env` (Step 12), open the two `.env` files and fill in this office's Cloudflare subdomain.
**`apps/Frontend/.env`** **`apps/Frontend/.env`**
```env ```env
@@ -192,7 +286,7 @@ Replace `yoursubdomain` with this office's actual subdomain (e.g. `summitdentalc
> If you skip this step, Cloudflare tunnel access will not work. LAN access still works without it. > If you skip this step, Cloudflare tunnel access will not work. LAN access still works without it.
### Step 11 — Set up the database ### Step 13 — Set up the database
```sh ```sh
# Run migrations # Run migrations
@@ -205,7 +299,7 @@ npm run db:generate
npm run db:seed npm run db:seed
``` ```
### Step 12 — Configure nginx ### Step 14 — Configure nginx
Install nginx: Install nginx:
@@ -217,6 +311,12 @@ sudo apt install -y nginx
nginx -v nginx -v
``` ```
> If Apache is already installed, it will also be bound to port 80/443 and nginx will fail to
> start. Disable it first:
> ```sh
> sudo systemctl disable --now apache2
> ```
The repo includes `nginx.conf` in the project root. Install it as the active site config: The repo includes `nginx.conf` in the project root. Install it as the active site config:
```sh ```sh
@@ -228,7 +328,7 @@ sudo nginx -t && sudo systemctl reload nginx
> **Important:** The `/api/` location block must include `proxy_set_header Authorization $http_authorization;` > **Important:** The `/api/` location block must include `proxy_set_header Authorization $http_authorization;`
> Without it, nginx strips the Authorization header and the backend returns "Access denied. No token provided." > Without it, nginx strips the Authorization header and the backend returns "Access denied. No token provided."
### Step 13 — Run the app ### Step 15 — Run the app
Open two terminals: Open two terminals:
@@ -245,7 +345,7 @@ cd apps/SeleniumService
.venv/bin/python3 agent.py .venv/bin/python3 agent.py
``` ```
### Step 14 — Create desktop shortcuts (optional) ### Step 16 — Create desktop shortcuts (optional)
Instead of opening two terminals manually every time, you can create desktop shortcuts that start and stop all services with a single double-click. Instead of opening two terminals manually every time, you can create desktop shortcuts that start and stop all services with a single double-click.
@@ -264,6 +364,67 @@ Two shortcuts will appear on your desktop:
> No username or path editing needed — the script automatically detects the current user's home folder. > No username or path editing needed — the script automatically detects the current user's home folder.
### Step 17 — Install RustDesk for remote support (optional)
Lets you remotely access this PC for troubleshooting/support without needing SSH.
```sh
wget -q https://github.com/rustdesk/rustdesk/releases/latest/download/rustdesk-1.4.9-x86_64.deb -O /tmp/rustdesk.deb
sudo apt install -y /tmp/rustdesk.deb
```
> Check [github.com/rustdesk/rustdesk/releases](https://github.com/rustdesk/rustdesk/releases) for the latest version number if the link above has moved on.
Get this machine's RustDesk ID:
```sh
rustdesk --get-id
```
Set a permanent password for unattended access (skip this to require manual approval on each connection instead):
```sh
rustdesk --password YOUR_STRONG_PASSWORD
```
On the connecting device, install RustDesk and enter this machine's ID and password to connect.
### Step 18 — Auto-start Chrome and RustDesk on login (optional)
Pairs well with [Step 1's auto-login](#step-1--enable-auto-login-lxqt--sddm) — the PC boots straight to the desktop with Chrome open and the RustDesk window visible, no manual clicks needed.
> The RustDesk **core service** (`rustdesk.service`) is already enabled at install time and runs at boot as root — remote access works even before anyone logs in, and it auto-spawns its own tray icon for the logged-in session on its own. This step additionally opens the full RustDesk **window** (showing the ID/password) on login, which the service alone does not do.
LXQt reads autostart entries from `~/.config/autostart/`. Create one `.desktop` file per app:
```sh
mkdir -p ~/.config/autostart
cat > ~/.config/autostart/google-chrome.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=Google Chrome
Exec=/usr/bin/google-chrome-stable
Icon=google-chrome
Terminal=false
X-GNOME-Autostart-enabled=true
EOF
cat > ~/.config/autostart/rustdesk-tray.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=RustDesk Tray
Exec=/usr/bin/rustdesk
Icon=rustdesk
Terminal=false
X-GNOME-Autostart-enabled=true
EOF
```
Log out and back in (or reboot) to verify — Chrome and the RustDesk window should both open on their own.
> Set a permanent password (Step 17) instead of relying on the default one-time password — a permanent password avoids the OTP-refresh confusion on autostart, where the window can appear to briefly show one password and then update to another as RustDesk finishes contacting the ID server.
--- ---
## 📖 Developer Documentation ## 📖 Developer Documentation
@@ -496,6 +657,44 @@ sudo systemctl start cloudflared
--- ---
### Variant — Tunnel scoped to Twilio webhooks only
The example above tunnels the whole app (`service: http://localhost:3000`) to the public
subdomain. If the only reason you need public access is Twilio's webhooks (inbound SMS/voice —
see [Twilio In-Browser Calling Setup](#twilio-in-browser-calling-setup-dial-pad) below), point
the tunnel at nginx's port-80 block instead, which only forwards `/api/twilio/*` and returns
`403` for everything else (see [nginx.conf](nginx.conf)) — the rest of the app stays unreachable
from the internet even though the tunnel is live.
Example actually used for Summit Dental Care's Twilio integration — paste this into
`/etc/cloudflared/config.yml` (`sudo nano /etc/cloudflared/config.yml`, this is a YAML file, not
the certbot `.ini` credentials file from the LAN HTTPS section above):
```yaml
tunnel: fc423bdb-eaae-4af5-bd6d-961a60b1e624
credentials-file: /home/gg/.cloudflared/fc423bdb-eaae-4af5-bd6d-961a60b1e624.json
ingress:
- hostname: summit.mydentalofficemanagement.com
service: http://localhost:80
- service: http_status:404
```
Then route DNS and install the service same as Steps 6–7 above:
```bash
cloudflared tunnel route dns summit-dental-twilio summit.mydentalofficemanagement.com
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
```
Skip Steps 8–9 (Vite `allowedHosts` / backend CORS) for this variant — the public hostname never
reaches the frontend or triggers a login/API CORS check, only the unauthenticated Twilio webhook
routes.
---
### Multi-office overview ### Multi-office overview
Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflict because each PC is a separate machine. Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflict because each PC is a separate machine.
@@ -508,6 +707,240 @@ Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflic
--- ---
## LAN HTTPS Setup (Trusted Certificate for Local Access)
Some browser features — notably the Screen Capture API used by the Copy Agent's screenshot
tool — only work in a "secure context" (`https://`, or the literal hostname `localhost`). Plain
`http://<LAN-ip>:3000` doesn't qualify, so staff would see the feature report itself as
unsupported.
The fix used here: a real, publicly-trusted certificate (Let's Encrypt) for a subdomain whose
DNS record points at this office's private LAN IP. Since the cert is issued via a DNS-01
challenge (not a live connection to the server), staff PCs need zero configuration — no local CA
to install, unlike a self-signed/mkcert certificate.
**How it works:**
- DNS: `local-<office>.mydentalofficemanagement.com` → `A` record → this PC's LAN IP, **unproxied**
("DNS only" in Cloudflare — proxying doesn't work for private IPs)
- nginx terminates TLS using the Let's Encrypt cert and only accepts connections from the office
subnet (`allow <subnet>; deny all;`)
- Staff browse to `https://local-<office>.mydentalofficemanagement.com` instead of the raw IP
This is separate from the Cloudflare Tunnel above — the tunnel is for public access (e.g. Twilio
webhooks), this is for trusted HTTPS on the local network only.
---
### Step 1 — Create the DNS record (in Cloudflare, not the registrar)
In the Cloudflare dashboard for `mydentalofficemanagement.com` → DNS → Add record:
- Type: `A`, Name: `local-<office>` (e.g. `local-summit`), Content: this PC's LAN IP
- **Proxy status: DNS only** (grey cloud) — required, Cloudflare can't proxy a private IP
### Step 2 — Install nginx (if not already done in Step 14 above)
```sh
sudo apt update
sudo apt install -y nginx
```
> If another web server (e.g. Apache) is already bound to port 80/443, nginx will fail to start.
> Check with `sudo ss -tlnp | grep -E ':80|:443'` and disable the conflicting service first:
> ```sh
> sudo systemctl disable --now apache2
> ```
### Step 3 — Install certbot with the Cloudflare DNS plugin
```sh
sudo apt update
sudo apt install -y certbot python3-certbot-dns-cloudflare
```
### Step 4 — Create a scoped Cloudflare API token
Cloudflare dashboard → profile icon → **My Profile** → **API Tokens** → **Create Token** → use the
**"Edit zone DNS"** template, restricted to **Specific zone → mydentalofficemanagement.com**.
Save it in a credentials file, root-only:
```sh
sudo mkdir -p /etc/letsencrypt
sudo nano /etc/letsencrypt/cloudflare.ini
```
```ini
dns_cloudflare_api_token = <paste token here>
```
```sh
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
```
> The same token can be reused across offices (Cloudflare tokens are scoped to the whole zone,
> not a single subdomain) — but issuing one token per server makes it easy to revoke just one if
> a machine is ever decommissioned.
### Step 5 — Issue the certificate
```sh
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d local-<office>.mydentalofficemanagement.com
```
Certbot adds a temporary DNS TXT record via the API to prove domain ownership, then removes it.
The cert lands at `/etc/letsencrypt/live/local-<office>.mydentalofficemanagement.com/` and
auto-renews via a systemd timer — no manual renewal steps.
### Step 6 — Point nginx at the new cert
Update the LAN server block in `nginx.conf` (`server_name`, `ssl_certificate`,
`ssl_certificate_key`, and the `allow <subnet>;` line) for this office, then reinstall and reload:
```sh
sudo cp nginx.conf /etc/nginx/sites-available/dental-app
sudo nginx -t && sudo systemctl reload nginx
```
> **If `nginx -t` fails with `could not build server_names_hash, you should increase
> server_names_hash_bucket_size: 64`:** the `local-<office>.mydentalofficemanagement.com`
> hostname is too long for nginx's default hash bucket size. Fix it in the main config (not this
> repo's `nginx.conf`) — `/etc/nginx/nginx.conf` already has a commented-out line for this inside
> the `http { }` block:
> ```sh
> sudo sed -i 's/# server_names_hash_bucket_size 64;/server_names_hash_bucket_size 128;/' /etc/nginx/nginx.conf
> sudo nginx -t && sudo systemctl reload nginx
> ```
### Step 7 — Allow the new hostname in Vite and backend CORS
- `apps/Frontend/vite.config.ts` → add the hostname to `server.allowedHosts`
- `apps/Backend/.env` → add `https://local-<office>.mydentalofficemanagement.com` to
`FRONTEND_URLS`
Staff can now use `https://local-<office>.mydentalofficemanagement.com` with a trusted padlock —
no certificate warnings, no CA install on any PC.
### Troubleshooting — router blocks the hostname (DNS rebinding protection)
Some routers (this has been confirmed on Verizon Fios gateways, e.g. the G3100) refuse to resolve
`local-<office>.mydentalofficemanagement.com` even though the DNS record is correct and public
resolvers (`1.1.1.1`, `8.8.8.8`) answer it fine. Symptoms: `ping`/`dig` against the hostname fails
or returns no answer when using the router as the DNS server, while `dig @1.1.1.1 <hostname>`
returns the right LAN IP.
Cause: the router's **DNS rebinding protection** blocks any public hostname that resolves to a
private IP (`192.168.x.x`) — a heuristic meant to stop DNS rebinding attacks, which happens to
match this setup's pattern (public domain → private LAN IP) exactly.
Preferred fix: on the router's admin page (e.g. `https://192.168.1.1` for Fios), look under
**Advanced → Network Settings → DNS Server** for a **DNS Rebind Protection** exception list, and
add `local-<office>.mydentalofficemanagement.com`. If no exception list exists, disabling DNS
Rebind Protection entirely also works, at the cost of that protection network-wide.
If you don't have router access, override DNS resolution per machine with a hosts-file entry
instead — this works because every OS checks its local hosts file before asking the router's DNS,
so the query never reaches the router:
**Debian/Linux server itself:**
```sh
sudo nano /etc/hosts
```
Add a line at the bottom (replace with this office's actual LAN IP and hostname):
```
192.168.1.229 local-<office>.mydentalofficemanagement.com
```
Save and exit — no service restart needed, `/etc/hosts` is checked before DNS automatically.
**Windows staff PCs (PowerShell):**
```powershell
# 1. Open PowerShell as Administrator, then confirm it's actually elevated
([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
# must print True — if False, close this window and reopen via right-click "Run as administrator"
# 2. Append the entry
Add-Content -Path C:\Windows\System32\drivers\etc\hosts -Value "192.168.1.229 local-<office>.mydentalofficemanagement.com"
# 3. Verify it saved
Get-Content C:\Windows\System32\drivers\etc\hosts -Tail 3
# 4. Flush the DNS cache and test
ipconfig /flushdns
ping local-<office>.mydentalofficemanagement.com
```
> **If `Add-Content` fails with "the process cannot access the file... being used by another
> process,"** antivirus software (Malwarebytes, Avast/AVG "Hosts File Guard", McAfee, and some VPN
> clients are known to do this) is locking the hosts file to prevent tampering. Temporarily
> disable real-time protection, redo step 2, verify with step 3, then **re-enable antivirus** —
> the hosts file entry itself doesn't need protection disabled to keep working, only to make the
> edit.
Editing Notepad directly instead of PowerShell is not recommended: saving
`C:\Windows\System32\drivers\etc\hosts` without true elevation fails silently in some cases (the
file appears saved but `LastWriteTime` never changes) — PowerShell's `Add-Content` surfaces a
clear permission error instead, which is easier to diagnose.
The real Let's Encrypt certificate from Step 5 keeps working normally regardless of how each
client resolves the hostname — it was validated via DNS-01 against Cloudflare at issuance time,
not by a live connection to the server, so staff still get a trusted padlock with no warnings.
This is a per-machine workaround; the router-level exception above is preferred when possible
since it fixes every device on the network in one place instead of requiring a hosts-file edit
(and possibly an antivirus fight) on every PC.
---
## Payment OCR Service Setup (Google Cloud Vision)
The Payment OCR Service (`apps/PaymentOCRService`, port 5003) uses Google Cloud Vision to read
payment/EOB documents and to locate exact text positions for the Windows Type Agent. It needs a
Google Cloud service-account key that is **not** included in the repo (it's a live secret and is
gitignored on purpose) — each PC needs its own copy placed locally.
### Step 1 — Enable the Cloud Vision API
1. Go to `console.cloud.google.com` and select (or create) the project this service should use
2. **APIs & Services → Library** → search for **"Cloud Vision API"** → click **Enable**
(skip if already enabled)
### Step 2 — Create a service account key
1. **IAM & Admin → Service Accounts** → either pick an existing service account for this app,
or **Create Service Account** (any name, e.g. `ocr-service`; no special roles needed beyond
default — Vision API access comes from the API being enabled on the project, not a role grant)
2. Open that service account → **Keys** tab → **Add Key → Create new key → JSON**
This immediately downloads a `.json` file to your browser's Downloads folder — **this is the
only time the private key content is shown**, so keep the file safe (a password manager or
secure backup, not just Downloads).
### Step 3 — Install the key on this PC
Move the downloaded file into `apps/PaymentOCRService/` and rename it to exactly
`google_credentials.json` — this is the filename `apps/PaymentOCRService/.env` already expects:
```sh
mv ~/Downloads/<your-downloaded-file>.json apps/PaymentOCRService/google_credentials.json
```
> `google_credentials.json` is gitignored on purpose — **never commit it**. If a key is ever
> accidentally exposed (committed, pasted, screenshotted), go back to the Keys tab in Step 2 and
> delete it, then generate a new one.
### Step 4 — Verify
With the service running (Step 15 above starts it as part of `npm run dev`, or run it directly —
see `apps/PaymentOCRService/README.md`):
```sh
curl localhost:5003/health
# should report "GOOGLE_APPLICATION_CREDENTIALS set: True"
```
---
## Twilio In-Browser Calling Setup (Dial Pad) ## Twilio In-Browser Calling Setup (Dial Pad)
The dial pad on the Patient Connection page lets staff make real phone calls directly through the browser (mic + speaker) using Twilio Voice SDK. One-time setup is required in the Twilio Console. The dial pad on the Patient Connection page lets staff make real phone calls directly through the browser (mic + speaker) using Twilio Voice SDK. One-time setup is required in the Twilio Console.
@@ -668,4 +1101,26 @@ mkdir -p /home/ff/.claude/projects/-home-ff-Desktop-DentalManagementMH06/memory/
cp -r /media/usb/claude-memory-backup/* /home/ff/.claude/projects/-home-ff-Desktop-DentalManagementMH06/memory/ cp -r /media/usb/claude-memory-backup/* /home/ff/.claude/projects/-home-ff-Desktop-DentalManagementMH06/memory/
``` ```
---
## ngrok vs. Cloudflare Tunnel (for exposing Twilio webhooks/streams)
Twilio just needs a public HTTPS/WSS endpoint to reach — either tunnel provider works, and neither
is required by name. Trade-offs when choosing between them for the public tunnel (`CLOUDFLARE_HOST`
today; the code doesn't actually validate it's a Cloudflare domain, it's just named that way):
| | Cloudflare Tunnel (current setup) | ngrok |
|---|---|---|
| **Fixed hostname** | Yes — same subdomain forever, survives reboots | Only on a paid static domain; **free tier assigns a new random subdomain every time the `ngrok` process restarts** (machine reboot, crash, manual relaunch) |
| **Auto-start on boot** | Already set up via `systemctl enable cloudflared` | Possible (systemd service), but only fixes the tunnel coming back up — it does **not** stop the free-tier URL from changing on each restart |
| **After a URL change** | N/A — doesn't happen | Must update `CLOUDFLARE_HOST` in `.env`, restart the backend, and update the URL(s) configured in the Twilio Console |
| **Setup cost** | One-time per office (already documented above) | Quick to spin up for local dev/testing |
| **Latency to Twilio** | No reliable general winner — depends on actual network path (nearest PoP, peering) for this specific office, not the provider's name. Cloudflare's edge network is larger/more distributed in general, but the only way to know for a given office is to measure real round-trip time, not assume | |
| **Best for** | Production / long-running office deployments | Local dev/testing, or short-lived debugging sessions |
**Bottom line:** ngrok is fine for local development, but for an office's live Twilio integration
(webhooks and any Media Stream WebSocket), the Cloudflare Tunnel setup above is preferred because
its hostname doesn't change across reboots — avoiding the reconfigure-`.env`-and-Twilio-Console
cycle that free-tier ngrok requires every time the tunnel restarts.
The memory is plain markdown files and can also be copied manually via a USB drive or file manager. Enable "show hidden files" (Ctrl+H) in the file manager to see the `.claude` folder. The memory is plain markdown files and can also be copied manually via a USB drive or file manager. Enable "show hidden files" (Ctrl+H) in the file manager to see the `.claude` folder.
+1
View File
@@ -4,6 +4,7 @@ PORT=5000
CLOUDFLARE_HOST= CLOUDFLARE_HOST=
FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com FRONTEND_URLS=http://localhost:3000,https://local-summit.mydentalofficemanagement.com
SELENIUM_AGENT_BASE_URL=http://localhost:5002 SELENIUM_AGENT_BASE_URL=http://localhost:5002
WINDOWS_AGENT_TOKEN=dev-windows-agent-token
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92 LICENSE_SECRET=3aa4ab937e46c6863b9e3c2b591a595b31ea3af1060bf5e7961ad722a8b54f92
DB_HOST=localhost DB_HOST=localhost
+1
View File
@@ -4,6 +4,7 @@ PORT=5000
CLOUDFLARE_HOST= CLOUDFLARE_HOST=
FRONTEND_URLS=http://localhost:3000 FRONTEND_URLS=http://localhost:3000
SELENIUM_AGENT_BASE_URL=http://localhost:5002 SELENIUM_AGENT_BASE_URL=http://localhost:5002
WINDOWS_AGENT_TOKEN=
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
DB_HOST=localhost DB_HOST=localhost
DB_USER=postgres DB_USER=postgres
+1
View File
@@ -33,6 +33,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"twilio": "^6.0.0", "twilio": "^6.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",
@@ -0,0 +1,73 @@
// Simulates DentalAgent.exe connecting to the backend's /agent socket.io namespace,
// so the Type Agent page's "Windows Agent: Connected" status can be tested end-to-end
// before the real pyautogui-based Windows client exists.
//
// Usage: node scripts/fake-windows-agent.js [serverUrl]
// Requires WINDOWS_AGENT_TOKEN to match the backend's .env value.
// The server identifies agents by connection IP address, not anything sent here.
const { io } = require("socket.io-client");
const serverUrl = process.argv[2] || "http://localhost:5000";
const token = process.env.WINDOWS_AGENT_TOKEN || "dev-windows-agent-token";
const socket = io(`${serverUrl}/agent`, {
auth: { token },
reconnectionAttempts: 5,
});
socket.on("connect", () => {
console.log(`✅ Connected as fake Windows Agent, socket id: ${socket.id}`);
});
// 1x1 transparent PNG, base64 — stands in for a real screen capture.
const FAKE_SCREENSHOT =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
socket.on("cmd:screenshot", (_payload, ack) => {
console.log("📸 screenshot requested");
ack({ image: FAKE_SCREENSHOT });
});
socket.on("cmd:move", ({ x, y }, ack) => {
console.log(`🖱️ move to (${x}, ${y}) (no click)`);
ack({ ok: true });
});
socket.on("cmd:click", ({ x, y }, ack) => {
console.log(`🖱️ click at (${x}, ${y})`);
ack({ ok: true });
});
socket.on("cmd:double_click", ({ x, y }, ack) => {
console.log(`🖱️ double-click at (${x}, ${y})`);
ack({ ok: true });
});
socket.on("cmd:double_click_current", (_payload, ack) => {
console.log("🖱️ double-click at current cursor position");
ack({ ok: true });
});
socket.on("cmd:type", ({ text }, ack) => {
console.log(`⌨️ type "${text}"`);
ack({ ok: true });
});
socket.on("cmd:key", ({ key }, ack) => {
console.log(`⌨️ press key "${key}"`);
ack({ ok: true });
});
socket.on("connect_error", (err) => {
console.error("❌ Connection failed:", err.message);
});
socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected:", reason);
});
process.on("SIGINT", () => {
socket.close();
process.exit(0);
});
+9
View File
@@ -3,6 +3,9 @@ import { prisma as db } from "@repo/db/client";
export type ConversationStage = export type ConversationStage =
| "initial" | "initial"
| "reminder_initial" | "reminder_initial"
| "recall_initial"
| "recall_asked_datetime"
| "recall_asked_time_for_date"
| "greeted" | "greeted"
| "done" | "done"
| "new_patient_greeted" | "new_patient_greeted"
@@ -81,6 +84,12 @@ export async function startRescheduleConversation(userId: number, patientId: num
await setStage(userId, patientId, "asked_reschedule_confirm"); await setStage(userId, patientId, "asked_reschedule_confirm");
} }
// Recall: office proactively calls/texts an existing patient to offer a
// routine exam & cleaning (typically covered at no cost by insurance).
export async function startRecallConversation(userId: number, patientId: number): Promise<void> {
await setStage(userId, patientId, "recall_initial");
}
// ── Pending reschedule (in-memory — seconds-lived within a single exchange) ─── // ── Pending reschedule (in-memory — seconds-lived within a single exchange) ───
interface PendingReschedule { interface PendingReschedule {
+54 -3
View File
@@ -125,6 +125,7 @@ const ALIAS_MAP: Record<string, string> = {
"simple extraction": "extraction erupted", "simple extraction": "extraction erupted",
"surgical extraction": "extraction bone", "surgical extraction": "extraction bone",
"surgical ext": "extraction bone", "surgical ext": "extraction bone",
"surg ext": "extraction bone",
"baby tooth ext": "extraction primary", "baby tooth ext": "extraction primary",
"baby tooth extraction": "extraction primary", "baby tooth extraction": "extraction primary",
"primary tooth ext": "extraction primary", "primary tooth ext": "extraction primary",
@@ -211,6 +212,7 @@ const DIRECT_CODE_MAP: Record<string, string> = {
"primary tooth ext": "D7111", "primary tooth ext": "D7111",
"surgical extraction": "D7210", "surgical extraction": "D7210",
"surgical ext": "D7210", "surgical ext": "D7210",
"surg ext": "D7210",
"soft tissue impaction": "D7220", "soft tissue impaction": "D7220",
"partial bony": "D7230", "partial bony": "D7230",
"complete bony": "D7240", "complete bony": "D7240",
@@ -384,6 +386,48 @@ function parseRctCode(input: string): CdtMatch | null {
return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum) }; return { code, description: row?.Description ?? code, input, toothNumber: String(toothNum) };
} }
/**
* Levenshtein edit distance between two strings.
*/
function editDistance(a: string, b: string): number {
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i]![0] = i;
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i]![j] = a[i - 1] === b[j - 1]
? dp[i - 1]![j - 1]!
: 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
}
}
return dp[a.length]![b.length]!;
}
/**
* True if two words are the same or a plausible typo of each other
* (one edit, only for words long enough that a 1-char slip is unambiguous).
*/
function wordsMatch(a: string, b: string): boolean {
if (a === b) return true;
if (a.length < 5 || b.length < 5) return false;
return editDistance(a, b) <= 1;
}
/**
* Fuzzy lookup into ALIAS_MAP: tolerates a single typo per word
* (e.g. "comprehensiv exam" → "comprehensive exam").
*/
function fuzzyAliasLookup(cleaned: string): string | undefined {
if (ALIAS_MAP[cleaned]) return ALIAS_MAP[cleaned];
const queryWords = cleaned.split(/\s+/).filter(Boolean);
for (const key of Object.keys(ALIAS_MAP)) {
const keyWords = key.split(/\s+/).filter(Boolean);
if (keyWords.length !== queryWords.length) continue;
if (keyWords.every((w, i) => wordsMatch(w, queryWords[i]!))) return ALIAS_MAP[key];
}
return undefined;
}
/** /**
* Score how well a set of query tokens matches a code's description tokens. * Score how well a set of query tokens matches a code's description tokens.
* Each matched token contributes 1 point; shorter descriptions get a bonus * Each matched token contributes 1 point; shorter descriptions get a bonus
@@ -392,7 +436,14 @@ function parseRctCode(input: string): CdtMatch | null {
function score(queryTokens: string[], entry: { tokens: Set<string>; description: string }): number { function score(queryTokens: string[], entry: { tokens: Set<string>; description: string }): number {
let hits = 0; let hits = 0;
for (const t of queryTokens) { for (const t of queryTokens) {
if (entry.tokens.has(t)) hits++; if (entry.tokens.has(t)) {
hits++;
} else if (t.length >= 5) {
// Tolerate a single typo (e.g. "comprehensiv" → "comprehensive")
for (const dt of entry.tokens) {
if (wordsMatch(t, dt)) { hits++; break; }
}
}
} }
if (hits === 0) return 0; if (hits === 0) return 0;
// Tie-break: prefer shorter descriptions (more specific match) // Tie-break: prefer shorter descriptions (more specific match)
@@ -423,8 +474,8 @@ function matchOne(input: string): CdtMatch | null {
return { code, description: row?.Description ?? code, input }; return { code, description: row?.Description ?? code, input };
} }
// Apply alias before tokenizing // Apply alias before tokenizing (fuzzy-tolerant of single-letter typos)
const normalized = ALIAS_MAP[cleaned] ?? cleaned; const normalized = fuzzyAliasLookup(cleaned) ?? cleaned;
const queryTokens = normalized const queryTokens = normalized
.replace(/[^a-z0-9\s]/g, " ") .replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/) .split(/\s+/)
+48
View File
@@ -0,0 +1,48 @@
import twilio from "twilio";
import { prisma as db } from "@repo/db/client";
import { storage } from "../storage";
const CONFIRMATION_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
English: (d, t) => `Confirmed! Your appointment is on ${d} at ${t}. We look forward to seeing you.`,
Spanish: (d, t) => `¡Confirmado! Su cita es el ${d} a las ${t}. Le esperamos.`,
Portuguese: (d, t) => `Confirmado! Sua consulta é em ${d} às ${t}. Aguardamos sua visita.`,
Mandarin: (d, t) => `已确认!您的预约在 ${d} ${t}。期待您的光临。`,
Cantonese: (d, t) => `已確認!您的預約喺 ${d} ${t}。期待見到您。`,
Arabic: (d, t) => `تم التأكيد! موعدك في ${d} الساعة ${t}. نتطلع لرؤيتك.`,
"Haitian Creole": (d, t) => `Konfime! Randevou ou se ${d} a ${t}. N'ap tann ou.`,
};
/**
* Sends a standalone appointment-confirmation text, independent of whichever
* channel (SMS chat or AI phone call) the booking conversation happened on —
* a voice-booked appointment would otherwise leave the patient with nothing
* to refer back to once the call ends. Swallows all errors so a notification
* failure never breaks the booking flow that already succeeded.
*/
export async function sendAppointmentConfirmationSms(
userId: number,
patientId: number,
dateLabel: string,
time: string,
lang: string,
): Promise<void> {
try {
const [twilioSettings, patient] = await Promise.all([
storage.getTwilioSettings(userId),
db.patient.findUnique({ where: { id: patientId }, select: { phone: true } }),
]);
if (!twilioSettings || !patient?.phone) return;
const build = CONFIRMATION_FALLBACKS[lang] ?? CONFIRMATION_FALLBACKS["English"]!;
const body = build(dateLabel, time);
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
await client.messages.create({ body, from: twilioSettings.phoneNumber, to: patient.phone });
await storage.createCommunication({
patientId, userId, channel: "sms", direction: "outbound", status: "sent", body,
});
} catch {
// Silent — don't crash the booking flow over a notification failure
}
}
@@ -79,6 +79,9 @@ Intents:
Insurance abbreviations are: mh, masshealth, bcbs, cca, dentaquest — anything else is a patient name Insurance abbreviations are: mh, masshealth, bcbs, cca, dentaquest — anything else is a patient name
- eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name) - eligibility_by_id : user provides a SINGLE member ID and date of birth (no patient name)
e.g. "check masshealth for 100xxxx, 10/10/1988" e.g. "check masshealth for 100xxxx, 10/10/1988"
e.g. "100xxxx 10/10/1988" or "100xxxx, 10/10/1988" — a bare member ID + DOB with
NO verb and NO insurance keyword is STILL eligibility_by_id (default to MassHealth,
omit insuranceHint). Do NOT classify this as "general".
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026" e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026" e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
@@ -219,6 +222,20 @@ Rules:
// ─── Classifier ─────────────────────────────────────────────────────────────── // ─── Classifier ───────────────────────────────────────────────────────────────
// Deterministic fast-path for a bare "memberId DOB" (or "DOB memberId") message with
// 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.
function parseBareIdDob(message: string): { memberId: string; dob: string } | null {
const text = message.trim();
const dobPattern = /(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{4})/;
const idThenDob = text.match(new RegExp(`^([A-Za-z0-9]{3,})[\\s,]+${dobPattern.source}$`));
if (idThenDob) return { memberId: idThenDob[1]!, dob: idThenDob[2]!.replace(/-/g, "/") };
const dobThenId = text.match(new RegExp(`^${dobPattern.source}[\\s,]+([A-Za-z0-9]{3,})$`));
if (dobThenId) return { memberId: dobThenId[2]!, dob: dobThenId[1]!.replace(/-/g, "/") };
return null;
}
export async function classifyInternalChat( export async function classifyInternalChat(
message: string, message: string,
apiKey: string, apiKey: string,
@@ -234,6 +251,16 @@ export async function classifyInternalChat(
"I can search for a patient, check eligibility, run check & claim, schedule appointments, 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.",
}; };
const bare = parseBareIdDob(message);
if (bare) {
return {
intent: "eligibility_by_id",
memberId: bare.memberId,
dob: bare.dob,
fallbackReply: `Checking eligibility for Member ID ${bare.memberId}...`,
};
}
if (!apiKey) return fallback; if (!apiKey) return fallback;
// Prefer the client's local date (avoids UTC midnight rollover for US timezones) // Prefer the client's local date (avoids UTC midnight rollover for US timezones)
+11 -39
View File
@@ -113,6 +113,7 @@ interface StorageLike {
}): Promise<any[] | null>; }): Promise<any[] | null>;
getPatientByInsuranceId(id: string): Promise<any | null>; getPatientByInsuranceId(id: string): Promise<any | null>;
getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>; getPatientByInsuranceIdAndDob(id: string, dob: Date): Promise<any | null>;
getPatientByInsuranceIdNoDob(id: string): Promise<any | null>;
createAppointment(appointment: any): Promise<any>; createAppointment(appointment: any): Promise<any>;
getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>; getAppointmentsByDateForUser(dateStr: string, userId: number): Promise<any[]>;
getOfficeHours(userId: number): Promise<any | null>; getOfficeHours(userId: number): Promise<any | null>;
@@ -172,7 +173,11 @@ async function findPatientByMemberId(
if (dobDate && !isNaN(dobDate.getTime())) { if (dobDate && !isNaN(dobDate.getTime())) {
const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate); const byCombo = await storage.getPatientByInsuranceIdAndDob(memberId, dobDate);
if (byCombo) return byCombo; if (byCombo) return byCombo;
// DOB provided but no record with that combo → this is a new family member not yet in DB // No exact DOB match — check for an existing patient with this memberId but no DOB on
// file (e.g. imported from a PDF extraction that didn't capture birthdates) before
// concluding this is a brand-new family member not yet in DB.
const byIdNoDob = await storage.getPatientByInsuranceIdNoDob(memberId);
if (byIdNoDob) return byIdNoDob;
return null; return null;
} }
return storage.getPatientByInsuranceId(memberId); return storage.getPatientByInsuranceId(memberId);
@@ -428,28 +433,12 @@ async function handleEligibilityById(
}; };
} }
// Determine siteKey // Determine siteKey (defaults to MassHealth when no payer is specified)
const siteKey = resolveSiteKey( const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null, patient?.insuranceProvider ?? null,
c.insuranceHint ?? null c.insuranceHint ?? null
); );
if (!siteKey) {
const name = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Found ${name} but couldn't determine the insurance type. Which insurance should I use?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob: resolvedDob,
patient,
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
},
};
}
const label = patient const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`; : `Member ID ${memberId}`;
@@ -985,29 +974,12 @@ async function handleCheckAndClaim(
}; };
} }
// 2. Determine siteKey // 2. Determine siteKey (defaults to MassHealth when no payer is specified)
const siteKey = resolveSiteKey( const siteKey = resolveSiteKey(
patient?.insuranceProvider ?? null, patient?.insuranceProvider ?? null,
c.insuranceHint ?? null c.insuranceHint ?? null
); );
if (!siteKey) {
const label = patient
? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim()
: `Member ID ${memberId}`;
return {
reply: `Found ${label} but couldn't determine the insurance type. Which insurance should I use?`,
action: "need_insurance_clarification",
actionData: {
memberId,
dob,
patient,
procedureNames: c.procedureNames ?? [],
options: ["MassHealth", "BCBS MA", "CCA", "Tufts SCO", "Delta Dental MA", "United SCO / Dental Hub"],
},
};
}
// 3. Map procedure names → CDT codes (custom aliases take priority) // 3. Map procedure names → CDT codes (custom aliases take priority)
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []); const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
const cdtResults: CdtResult[] = procedureNames.length > 0 const cdtResults: CdtResult[] = procedureNames.length > 0
@@ -1456,13 +1428,13 @@ export async function createAppointmentToday(
* Determine siteKey from: * Determine siteKey from:
* 1. Patient's stored insuranceProvider (most authoritative) * 1. Patient's stored insuranceProvider (most authoritative)
* 2. Insurance hint from the chat message * 2. Insurance hint from the chat message
* 3. null → caller must ask for clarification * 3. No payer specified → default to MassHealth ("MH")
*/ */
function resolveSiteKey( function resolveSiteKey(
storedProvider: string | null, storedProvider: string | null,
hint: string | null hint: string | null
): string | null { ): string {
if (storedProvider) return deriveSiteKey(storedProvider); if (storedProvider) return deriveSiteKey(storedProvider);
if (hint) return deriveSiteKey(hint); if (hint) return deriveSiteKey(hint);
return null; return "MH";
} }
@@ -0,0 +1,689 @@
import twilio from "twilio";
import { prisma as db } from "@repo/db/client";
import { storage } from "../storage";
import { runReminderGraph } from "./reminder-graph";
import { runRecallGraph, runRecallBookingStep, hasDateOrTimeSignal } from "./recall-graph";
import { runNewPatientStep } from "./new-patient-graph";
import {
runRescheduleStep,
parseDateOnlyFromMessage,
parseTime,
isOfficeDayOpen,
isWithinOfficeHours,
getOfficeHoursDisplay,
timeLabel,
} from "./reschedule-graph";
import { getLlm, type AiProvider } from "./llm-factory";
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
import { sendAppointmentConfirmationSms } from "./confirmationSms";
import {
getPendingReschedule, setPendingReschedule, clearPendingReschedule,
setStage, type ConversationStage,
} from "./aiHandoffStore";
// Shared, channel-agnostic implementation of the patient conversation rules
// (reminder / reschedule / new-patient graphs, plus the inline MassHealth and
// appointment-booking stages) used by both the SMS webhook and the AI voice
// call webhook, so a patient gets identical guardrails and outcomes
// regardless of channel.
export interface RoutedPatient {
id: number;
userId: number;
phone: string | null;
preferredLanguage: string | null;
}
export interface RouteTurnResult {
segments: string[];
nextStage: ConversationStage;
}
/** Get the patient's next scheduled appointment as a human-readable string. */
export async function getAppointmentDatetime(patientId: number): Promise<string> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const appt = await db.appointment.findFirst({
where: { patientId, status: "scheduled", date: { gte: today } },
orderBy: { date: "asc" },
});
if (!appt) return "";
const months = ["January","February","March","April","May","June",
"July","August","September","October","November","December"];
const d = new Date(appt.date);
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} at ${appt.startTime}`;
}
/** Substitute {officeName} in a template string. */
export function applyOfficeName(template: string, name: string): string {
return template.replace(/\{officeName\}/g, name || "our dental office");
}
/** Save an outbound SMS message (always sms channel — used for async follow-ups). */
async function saveOutboundSms(patientId: number, body: string): Promise<void> {
await storage.createCommunication({
patientId, channel: "sms", direction: "outbound", status: "sent", body,
});
}
/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */
function normalizeDob(raw: string): string {
const parts = raw.split(/[\/\-\.]/);
if (parts.length !== 3) return raw;
const [m, d, y] = parts;
const mm = String(parseInt(m!, 10)).padStart(2, "0");
const dd = String(parseInt(d!, 10)).padStart(2, "0");
const yyyy = y!.length === 2 ? `20${y}` : y!;
return `${mm}/${dd}/${yyyy}`;
}
/**
* Extract MassHealth Member ID and date of birth from free text.
* Tries regex first, falls back to LLM extraction.
*/
async function parseMassHealthInfo(
message: string,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<{ memberId: string | null; dob: string | null }> {
const idMatch = message.match(/\b(\d{8,12})\b/);
const dobMatch = message.match(/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})\b/);
if (idMatch && dobMatch) {
const [, m, d, y] = dobMatch;
const year = y!.length === 2 ? `20${y}` : y;
return { memberId: idMatch[1]!, dob: normalizeDob(`${m}/${d}/${year}`) };
}
try {
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{
role: "system",
content:
'Extract the insurance member ID and date of birth from the patient message. ' +
'Return ONLY valid JSON: {"memberId":"...","dob":"MM/DD/YYYY"}. Use null for missing fields.',
},
{ role: "user", content: message },
]);
const raw = String(res.content).replace(/```json|```/g, "").trim();
const json = JSON.parse(raw);
const dob = json.dob ? normalizeDob(String(json.dob)) : null;
return { memberId: json.memberId ?? null, dob };
} catch {
return { memberId: null, dob: null };
}
}
/**
* Run MassHealth eligibility check in the background (after replying to the
* patient) and send the result as a follow-up SMS, regardless of which
* channel (SMS or voice call) triggered the check.
*/
async function runMassHealthCheckAndNotify(
patient: RoutedPatient,
memberId: string,
dob: string,
apiKey: string,
isExistingPatient = false
): Promise<void> {
try {
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH");
if (!credentials) return;
const twilioSettings = await storage.getTwilioSettings(patient.userId);
if (!twilioSettings || !patient.phone) return;
await runEligibilityProcessor({
userId: patient.userId,
insuranceId: memberId,
formDob: dob,
enrichedPayload: {
memberId,
dateOfBirth: dob,
insuranceSiteKey: "MH",
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
},
});
const updated = await db.patient.findUnique({
where: { id: patient.id },
select: { status: true, firstName: true },
});
const lang = patient.preferredLanguage || "English";
const active = updated?.status === "ACTIVE";
const activeMessages: Record<string, string> = {
English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?",
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?",
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?",
Mandarin: "好消息!您的MassHealth保险有效。我们可以为您安排预约!您希望什么日期和时间?",
Cantonese: "好消息!您的MassHealth保險有效。我們可以為您安排預約!您希望什麼日期和時間?",
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟",
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?",
};
const inactiveMessagesNew: Record<string, string> = {
English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?",
Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?",
Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?",
Mandarin: "很遗憾,您的MassHealth保险似乎无效。您还有其他保险吗?",
Cantonese: "很遺憾,您的MassHealth保險似乎無效。您還有其他保險嗎?",
Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟",
"Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?",
};
const inactiveMessagesExisting: Record<string, string> = {
English: "We checked your MassHealth coverage. Unfortunately the plan appears inactive or could not be verified. Would you still like to schedule an examination appointment as a self-pay patient?",
Spanish: "Verificamos su cobertura de MassHealth. Lamentablemente el plan aparece inactivo o no pudo ser verificado. ¿Le gustaría programar una cita de examen como paciente de pago particular?",
Portuguese: "Verificamos sua cobertura MassHealth. Infelizmente o plano parece inativo ou não pôde ser verificado. Gostaria de agendar uma consulta de exame como paciente particular?",
Mandarin: "我们查看了您的MassHealth保险。遗憾的是,保险似乎无效或无法验证。您仍然希望以自费方式预约检查吗?",
Cantonese: "我們查看了您的MassHealth保險。遺憾地,保險似乎無效或無法核實。您仍然希望以自費方式預約檢查嗎?",
Arabic: "تحققنا من تغطيتك من MassHealth. للأسف يبدو أن الخطة غير نشطة أو لا يمكن التحقق منها. هل تودّ تحديد موعد فحص كمريض يدفع من حسابه الخاص؟",
"Haitian Creole": "Nou te verifye kouvèti MassHealth ou. Malerezman plan an sanble inaktif oswa pa ka verifye. Èske ou ta renmen pran yon randevou egzamen kòm pasyan ki peye poukont li?",
};
const resultText = active
? (activeMessages[lang] ?? activeMessages["English"]!)
: isExistingPatient
? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!)
: (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!);
const nextStage: ConversationStage = active
? "asked_appointment_time"
: isExistingPatient
? "asked_self_pay"
: "asked_other_insurance_after_inactive";
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
await client.messages.create({
body: resultText,
from: twilioSettings.phoneNumber,
to: patient.phone,
});
await saveOutboundSms(patient.id, resultText);
await setStage(patient.userId, patient.id, nextStage);
} catch {
// Silent — don't crash the caller
}
}
async function finish(
userId: number, patientId: number,
segments: string[], nextStage: ConversationStage
): Promise<RouteTurnResult> {
await setStage(userId, patientId, nextStage);
return { segments, nextStage };
}
/**
* Process one patient message (SMS body or a transcribed spoken turn) against
* the current ConversationStage and return what to say next plus the stage
* to advance to. Persists the resulting stage itself. Does NOT log the
* Communication row for the reply — callers do that per-channel.
*/
export async function routePatientTurn(params: {
patient: RoutedPatient;
stage: ConversationStage;
message: string;
activeAi: { provider: AiProvider; key: string; model: string };
chatTemplates: Record<string, string>;
officeName: string;
}): Promise<RouteTurnResult> {
const { patient, stage, message, activeAi, chatTemplates, officeName } = params;
const language = patient.preferredLanguage || "English";
// ── Stage: reminder_initial → self-intro + classify yes/no/other ────────
if (stage === "reminder_initial") {
const rawGreeting = chatTemplates.reminderGreeting ||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7.`;
const introText = applyOfficeName(rawGreeting, officeName);
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: intentReply, intent } = await runReminderGraph(
message, activeAi.key, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
);
if (intentReply) {
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
if (intent === "no") {
const hasDateInMessage =
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) ||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message);
if (hasDateInMessage) {
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [introText, rescheduleReply], rescheduleNextStage);
}
}
return finish(patient.userId, patient.id, [introText, intentReply], nextStage);
}
// No clear intent detected — send only the intro and wait for next reply
return finish(patient.userId, patient.id, [introText], "greeted");
}
// ── Stage: recall_initial → office proactively offered an existing patient
// a routine exam & cleaning (usually covered at no cost by insurance); the
// pitch itself was already spoken/sent as the opening greeting, so this
// just classifies the patient's yes/no. On yes, it books directly onto the
// schedule (Column A, 30-minute slots) via runRecallBookingStep — unlike
// reschedule, a recall patient has no existing appointment to move, so this
// is a real create-appointment path, not a shortcut into the reschedule flow.
if (stage === "recall_initial") {
const { reply: intentReply, intent } = await runRecallGraph(
message, activeAi.key, language, chatTemplates.generalFallback, activeAi.provider, activeAi.model
);
if (intentReply) {
if (intent === "yes") {
// Patient may have already given a date (or date + time) in this same
// reply ("Tuesday 1pm") — try to book right away instead of asking again.
// A bare "yes"/"ok" has nothing to parse, so skip straight to asking for
// day + time instead of wasting a parse call that used to come back
// empty and send a mismatched "Sorry, I didn't catch a day" reply.
if (hasDateOrTimeSignal(message)) {
const { reply: bookingReply, nextStage: bookingNextStage } = await runRecallBookingStep(
message, "recall_asked_datetime", language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [bookingReply], bookingNextStage);
}
return finish(patient.userId, patient.id, [intentReply], "recall_asked_datetime");
}
if (intent === "no") {
return finish(patient.userId, patient.id, [intentReply], "done");
}
return finish(patient.userId, patient.id, [intentReply], "recall_initial");
}
return finish(patient.userId, patient.id, [chatTemplates.generalFallback || "Thank you for your message!"], "recall_initial");
}
// ── Recall booking flow — collecting/confirming the day & time, then
// creating the appointment (see recall-graph.ts: runRecallBookingStep) ──
const recallBookingStages: ConversationStage[] = ["recall_asked_datetime", "recall_asked_time_for_date"];
if (recallBookingStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runRecallBookingStep(
message, stage, language, patient.id, patient.userId, activeAi.key, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [aiReply], nextStage);
}
// ── Stage: greeted → classify yes/no for appointment reminder ──────────
if (stage === "greeted") {
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: aiReply, intent } = await runReminderGraph(
message, activeAi.key, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
);
if (aiReply) {
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
if (intent === "no") {
const hasDateInMessage =
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(message) ||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(message);
if (hasDateInMessage) {
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
message, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [rescheduleReply], rescheduleNextStage);
}
}
return finish(patient.userId, patient.id, [aiReply], nextStage);
}
}
// ── Rescheduling flow stages ────────────────────────────────────────────
const rescheduleStages: ConversationStage[] = [
"asked_reschedule_confirm", "asked_reschedule_preference",
"asked_reschedule_asap", "asked_reschedule_next_week",
"asked_reschedule_time", "asked_reschedule_datetime",
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
];
if (rescheduleStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runRescheduleStep(
message, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [aiReply], nextStage);
}
// ── Stage: awaiting MassHealth member ID + DOB ─────────────────────────
if (stage === "awaiting_masshealth_info") {
const { memberId, dob } = await parseMassHealthInfo(message, activeAi.key, activeAi.provider, activeAi.model);
if (!memberId || !dob) {
const retryMessages: Record<string, string> = {
English: "I couldn't read your Member ID and date of birth. Please reply in this format: Member ID: 12345678 DOB: 01/01/1990",
Spanish: "No pude leer su número de miembro y fecha de nacimiento. Por favor responda así: ID: 12345678 Fecha: 01/01/1990",
Portuguese: "Não consegui ler seu número de membro e data de nascimento. Por favor responda assim: ID: 12345678 Data: 01/01/1990",
Mandarin: "我无法读取您的会员ID和出生日期。请按以下格式回复:ID: 12345678 生日: 01/01/1990",
Cantonese: "我無法讀取您的會員ID和出生日期。請按以下格式回覆:ID: 12345678 生日: 01/01/1990",
Arabic: "لم أتمكن من قراءة رقم العضوية وتاريخ الميلاد. يرجى الرد بالصيغة التالية: ID: 12345678 DOB: 01/01/1990",
"Haitian Creole": "Mwen pa t ka li ID manm ou ak dat nesans. Tanpri reponn konsa: ID: 12345678 DOB: 01/01/1990",
};
return finish(patient.userId, patient.id, [retryMessages[language] ?? retryMessages["English"]!], "awaiting_masshealth_info");
}
const checkingMessages: Record<string, string> = {
English: "Thank you! I'm checking your MassHealth eligibility now. I'll send you the result in a moment.",
Spanish: "¡Gracias! Estoy verificando su elegibilidad de MassHealth ahora. Le enviaré el resultado en un momento.",
Portuguese: "Obrigado! Estou verificando sua elegibilidade MassHealth agora. Enviarei o resultado em instantes.",
Mandarin: "谢谢!我正在查询您的MassHealth资格。稍后我会发送结果给您。",
Cantonese: "多謝!我正在查詢您的MassHealth資格。稍後我會發送結果給您。",
Arabic: "شكراً! أقوم بالتحقق من أهليتك في MassHealth الآن. سأرسل لك النتيجة قريباً.",
"Haitian Creole": "Mèsi! Mwen ap verifye kalifikasyon MassHealth ou kounye a. M ap voye rezilta a nan yon ti moman.",
};
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
runMassHealthCheckAndNotify(patient, memberId, dob, activeAi.key).catch(() => {});
return finish(patient.userId, patient.id, [checkingMsg], "done");
}
// ── Stage: existing patient said YES to same insurance ──────────────────
if (stage === "asked_existing_insurance") {
const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(message);
if (saysYes) {
const patientRecord = await db.patient.findUnique({
where: { id: patient.id },
select: { insuranceProvider: true, insuranceId: true, dateOfBirth: true },
});
const isMassHealth = /masshealth|mass health|masscare|medicaid/i.test(
patientRecord?.insuranceProvider ?? ""
);
if (isMassHealth && patientRecord?.insuranceId) {
let dobStr = "";
if (patientRecord.dateOfBirth) {
const d = new Date(patientRecord.dateOfBirth);
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const yy = d.getUTCFullYear();
dobStr = `${mm}/${dd}/${yy}`;
}
const checkingMessages: Record<string, string> = {
English: "Please wait about 30-60 seconds! I'm double-checking your MassHealth coverage right now.",
Spanish: "¡Por favor espere unos 30-60 segundos! Estoy verificando su cobertura de MassHealth ahora mismo.",
Portuguese: "Por favor aguarde cerca de 30-60 segundos! Estou verificando sua cobertura MassHealth agora.",
Mandarin: "请等待约30-60秒!我现在正在为您核查MassHealth保险。",
Cantonese: "請等待約30-60秒!我現在正在為您核查MassHealth保險。",
Arabic: "يرجى الانتظار حوالي 30-60 ثانية! أقوم الآن بالتحقق من تغطيتك في MassHealth.",
"Haitian Creole": "Tanpri tann anviwon 30-60 segonn! Mwen ap verifye kouvèti MassHealth ou kounye a.",
};
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
runMassHealthCheckAndNotify(patient, patientRecord.insuranceId, dobStr, activeAi.key, true).catch(() => {});
return finish(patient.userId, patient.id, [checkingMsg], "done");
}
}
// Not MassHealth or said NO — fall through to normal graph handling
}
// ── Stage: asked_new_or_reschedule ───────────────────────────────────────
if (stage === "asked_new_or_reschedule") {
const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(message);
const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(message);
if (isReschedule) {
const apptDatetime = await getAppointmentDatetime(patient.id);
if (apptDatetime) {
const foundMsgs: Record<string, string> = {
English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`,
Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`,
Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`,
Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`,
Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`,
Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`,
"Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`,
};
return finish(patient.userId, patient.id, [foundMsgs[language] ?? foundMsgs["English"]!], "asked_reschedule_datetime");
} else {
const notFoundMsgs: Record<string, string> = {
English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.",
Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.",
Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.",
Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。",
Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。",
Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.",
"Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.",
};
return finish(patient.userId, patient.id, [notFoundMsgs[language] ?? notFoundMsgs["English"]!], "done");
}
}
void isNew; // ambiguous or explicit "new" both fall through to the same prompt
const newApptMsgs: Record<string, string> = {
English: "No problem! To get started, are you an existing patient or a new patient?",
Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?",
Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?",
Mandarin: "没问题!请问您是现有患者还是新患者?",
Cantonese: "沒問題!請問您是現有病人還是新病人?",
Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟",
"Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?",
};
return finish(patient.userId, patient.id, [newApptMsgs[language] ?? newApptMsgs["English"]!], "asked_new_or_existing");
}
// ── Stage: asked_appointment_time → parse date, check office hours ─────
if (stage === "asked_appointment_time") {
const parsedDate = await parseDateOnlyFromMessage(message, activeAi.key, activeAi.provider, activeAi.model);
if (!parsedDate) {
const msgs: Record<string, string> = {
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.",
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.",
Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。",
Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。",
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.",
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.",
};
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time");
}
const { date, dateLabel } = parsedDate;
const dayCheck = await isOfficeDayOpen(date, patient.userId);
if (!dayCheck.open) {
const msgs: Record<string, string> = {
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
};
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_appointment_time");
}
setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel });
const askTimeMsgs: Record<string, string> = {
English: `What time do you prefer on ${dateLabel}?`,
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
Portuguese: `Que horário você prefere em ${dateLabel}?`,
Mandarin: `您希望在 ${dateLabel} 几点?`,
Cantonese: `您希望在 ${dateLabel} 幾點?`,
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
};
return finish(patient.userId, patient.id, [askTimeMsgs[language] ?? askTimeMsgs["English"]!], "asked_new_appt_time_for_date");
}
// ── Stage: asked_new_appt_time_for_date → parse time, check hours ──────
if (stage === "asked_new_appt_time_for_date") {
const pending = getPendingReschedule(patient.userId, patient.id);
if (!pending) {
return finish(patient.userId, patient.id, ["I lost track of the date. What day would you prefer?"], "asked_appointment_time");
}
const startTime = await parseTime(message, activeAi.key, activeAi.provider, activeAi.model);
if (!startTime) {
const msgs: Record<string, string> = {
English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`,
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`,
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`,
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点?例如:上午10点或下午2点。`,
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點?例如:上午10點或下午2點。`,
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`,
};
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date");
}
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId);
const timeLbl = timeLabel(startTime);
const msgs: Record<string, string> = {
English: hoursDisplay
? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
: `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`,
Spanish: hoursDisplay
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`,
Portuguese: hoursDisplay
? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
: `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`,
Mandarin: hoursDisplay
? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
: `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`,
Cantonese: hoursDisplay
? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
: `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
Arabic: hoursDisplay
? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
: `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`,
"Haitian Creole": hoursDisplay
? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
: `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`,
};
return finish(patient.userId, patient.id, [msgs[language] ?? msgs["English"]!], "asked_new_appt_time_for_date");
}
clearPendingReschedule(patient.userId, patient.id);
const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
const [nh, nm] = startTime.split(":").map(Number);
const endTotal = nh! * 60 + nm! + 60;
const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`;
const [patientRecord, firstStaff] = await Promise.all([
db.patient.findUnique({
where: { id: patient.id },
select: { firstName: true, lastName: true },
}),
db.staff.findFirst({
where: { userId: patient.userId },
orderBy: { id: "asc" },
}),
]);
if (firstStaff) {
try {
await storage.createAppointment({
patientId: patient.id,
userId: patient.userId,
staffId: firstStaff.id,
title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(),
date: pending.newDate,
startTime,
endTime,
type: "checkup",
status: "scheduled",
movedByAi: true,
} as any);
await sendAppointmentConfirmationSms(patient.userId, patient.id, pending.dayLabel, timeLabel(startTime), language);
} catch { /* silent — message still sent, staff can create manually */ }
}
const confirmMsgs: Record<string, string> = {
English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`,
Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`,
Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`,
Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`,
Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`,
Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`,
"Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`,
};
return finish(patient.userId, patient.id, [confirmMsgs[language] ?? confirmMsgs["English"]!], "done");
}
// ── Stage: new_patient_greeted + multi-step new patient stages ──────────
const newPatientStages: ConversationStage[] = [
"new_patient_greeted", "asked_new_or_existing",
"asked_new_patient_insurance", "asked_insurance_type",
"asked_masshealth_check_consent", "asked_existing_insurance",
"asked_appointment_preference",
"asked_self_pay", "asked_other_insurance_after_inactive",
"collecting_contact_info",
];
if (newPatientStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runNewPatientStep(
message, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model
);
return finish(patient.userId, patient.id, [aiReply], nextStage);
}
// ── Stage: done → closing thank-you reply ────────────────────────────────
if (stage === "done") {
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(message);
if (isThanks) {
const apptDatetime = await getAppointmentDatetime(patient.id);
const CLOSING: Record<string, string> = {
English: apptDatetime
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
: `Thank you for choosing our office! We look forward to seeing you soon.`,
Spanish: apptDatetime
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
: `¡Gracias por elegirnos! Le esperamos pronto.`,
Portuguese: apptDatetime
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
Mandarin: apptDatetime
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
: `感谢您选择我们!期待很快见到您。`,
Cantonese: apptDatetime
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
: `感謝您選擇我們!期待很快見到您。`,
Arabic: apptDatetime
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
"Haitian Creole": apptDatetime
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
};
const fallback = CLOSING[language] ?? CLOSING["English"]!;
if (apptDatetime) {
try {
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const res = await llm.invoke([
{
role: "system",
content: `You are a friendly dental office AI assistant. The patient just said "${message}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
},
{ role: "user", content: message },
]);
const aiMsg = String(res.content).trim();
if (aiMsg) return finish(patient.userId, patient.id, [aiMsg], "done");
} catch { /* fall through to fallback */ }
}
return finish(patient.userId, patient.id, [fallback], "done");
}
}
// No matching stage/branch — hold stage steady with no reply.
return { segments: [], nextStage: stage };
}
+486
View File
@@ -0,0 +1,486 @@
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
import { getLlm, type AiProvider } from "./llm-factory";
import { prisma as db } from "@repo/db/client";
import { storage } from "../storage";
import {
type ConversationStage,
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
} from "./aiHandoffStore";
import {
parseDateOnlyFromMessage, parseTime, isOfficeDayOpen, isWithinOfficeHours,
getOfficeHoursDisplay, timeLabel,
} from "./reschedule-graph";
import { sendAppointmentConfirmationSms } from "./confirmationSms";
const GraphState = Annotation.Root({
message: Annotation<string>(),
intent: Annotation<string>(),
reply: Annotation<string>(),
language: Annotation<string>(),
generalFallback: Annotation<string>(),
});
type GraphStateType = typeof GraphState.State;
// ── Intent classifier — multilingual yes/no keywords (shared shape with reminder-graph) ──
/** True if `text` mentions a concrete day/date/time (e.g. "Tuesday", "3/10", "2pm").
* Shared by classifyNode (a bare "yes" vs. "yes, Tuesday at 1pm" both count as
* intent "yes") and by the caller in patient-conversation-router.ts, which uses
* it to decide whether to attempt an immediate booking parse or just ask for a
* day/time first. */
export function hasDateOrTimeSignal(text: string): boolean {
return (
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|today|tomorrow|next week)\b/i.test(text) ||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(text) ||
/\b\d{1,2}(:\d{2})?\s*(am|pm|a\.m\.|p\.m\.)\b/i.test(text)
);
}
function classifyNode(state: GraphStateType) {
const text = state.message.toLowerCase().trim();
const yesPatterns = /\b(yes|yeah|yep|yup|sure|ok|okay|sounds good|i'?d like (that|to)|let'?s do it|book it|schedule it|i want (an? )?appointment|i want to (come in|schedule|book)|great|perfect|absolutely|definitely|sí|si|claro|por supuesto|de acuerdo|seguro|sim|com certeza|好的|可以|好|明白|نعم|حسنا|موافق|wi|dakò|oke)\b/;
const noPatterns = /\b(no|nope|not interested|not right now|no thanks|can't|cannot|won't|not now|not available|busy|sorry|unable|no puedo|no gracias|ahora no|não posso|agora não|não|占线|无法|不用了|不需要|لا|لا أستطيع|pa kapab|pa ka)\b/;
// A specific day/date/time, or an appointment-request phrase, is itself a
// strong signal the patient wants to book — real replies rarely say a bare
// "yes", they jump straight to "Tuesday at 1pm" or "I want an appointment".
const mentionsDateOrTime = hasDateOrTimeSignal(text);
const mentionsAppointment = /\b(appointment|schedule|book|come in|check.?up|cleaning)\b/i.test(text);
if (noPatterns.test(text) && !mentionsDateOrTime) return { intent: "no" };
if (yesPatterns.test(text) || mentionsDateOrTime || mentionsAppointment) return { intent: "yes" };
return { intent: "other" };
}
function routeByIntent(state: GraphStateType): string {
if (state.intent === "yes") return "wantsBooking";
if (state.intent === "no") return "declines";
return "other";
}
// ── Fallbacks ──────────────────────────────────────────────────────────────
const WANTS_BOOKING_FALLBACKS: Record<string, string> = {
English: "Great! What day and time work best for you?",
Spanish: "¡Genial! ¿Qué día y hora le vienen mejor?",
Portuguese: "Ótimo! Qual dia e horário são melhores para você?",
Mandarin: "太好了!您哪天、几点方便?",
Cantonese: "太好喇!您邊日、幾點方便?",
Arabic: "رائع! ما هو اليوم والوقت الأنسب لك؟",
"Haitian Creole": "Bon! Ki jou ak ki lè ki pi bon pou ou?",
};
const DECLINES_FALLBACKS: Record<string, string> = {
English: "No problem at all! Feel free to reach out whenever you're ready. Have a great day!",
Spanish: "¡No hay problema! No dude en contactarnos cuando esté listo. ¡Que tenga un buen día!",
Portuguese: "Sem problemas! Entre em contato sempre que estiver pronto. Tenha um ótimo dia!",
Mandarin: "没问题!您准备好了随时联系我们。祝您愉快!",
Cantonese: "冇問題!您準備好隨時聯絡我們。祝您愉快!",
Arabic: "لا مشكلة على الإطلاق! لا تتردد في التواصل معنا عندما تكون مستعداً. أتمنى لك يوماً سعيداً!",
"Haitian Creole": "Pa gen pwoblèm! Ou ka kontakte nou nenpòt lè ou pare. Pase yon bon jounen!",
};
const GENERAL_FALLBACKS: Record<string, string> = {
English: "Thank you for your message! Our office staff will be happy to assist you shortly.",
Spanish: "¡Gracias por su mensaje! El personal de nuestra oficina estará encantado de ayudarle en breve.",
Portuguese: "Obrigado pela sua mensagem! Nossa equipe terá prazer em ajudá-lo em breve.",
Mandarin: "感谢您的留言!我们的办公室工作人员将很快为您提供帮助。",
Cantonese: "感謝您的留言!我們的辦公室工作人員將很快為您提供幫助。",
Arabic: "شكراً على رسالتك! سيسعد فريق مكتبنا بمساعدتك قريباً.",
"Haitian Creole": "Mèsi pou mesaj ou! Ekip biwo nou an pral kontan ede ou byento.",
};
// ── LangGraph nodes ───────────────────────────────────────────────────────────
async function wantsBookingNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = WANTS_BOOKING_FALLBACKS[lang] ?? WANTS_BOOKING_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content:
`You are a friendly dental office assistant. The patient agreed to come in for a routine exam and cleaning. Write a short, warm reply (1-2 sentences max) and ask what day AND time work best for them. Do NOT introduce yourself or say your name — the introduction has already been sent in a separate message. You MUST reply in ${lang}. No formatting, no extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
async function declinesNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = DECLINES_FALLBACKS[lang] ?? DECLINES_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content:
`You are a friendly dental office assistant. The patient does not want to schedule a checkup right now. Write a short, polite reply (1-2 sentences max) letting them know that's no problem and they can reach out whenever they're ready. Do NOT introduce yourself or say your name. You MUST reply in ${lang}. No formatting, no extra text.`,
},
{ role: "user", content: `Patient replied: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
async function otherNode(state: GraphStateType, config: any) {
const apiKey: string | undefined = config?.configurable?.apiKey;
const lang = state.language || "English";
const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!);
if (!apiKey) return { reply: fallback };
try {
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
content: `You are a friendly dental office AI assistant. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
},
{ role: "user", content: `Patient said: "${state.message}"` },
]);
return { reply: String(response.content) || fallback };
} catch {
return { reply: fallback };
}
}
// ── Graph ─────────────────────────────────────────────────────────────────────
const graph = new StateGraph(GraphState)
.addNode("classify", classifyNode)
.addNode("wantsBooking", wantsBookingNode)
.addNode("declines", declinesNode)
.addNode("other", otherNode)
.addEdge(START, "classify")
.addConditionalEdges("classify", routeByIntent, {
wantsBooking: "wantsBooking",
declines: "declines",
other: "other",
})
.addEdge("wantsBooking", END)
.addEdge("declines", END)
.addEdge("other", END)
.compile();
export async function runRecallGraph(
patientMessage: string,
apiKey: string,
language = "English",
generalFallback = "",
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string | null; intent: string | null }> {
const result = await graph.invoke(
{ message: patientMessage, intent: "", reply: "", language, generalFallback },
{ configurable: { apiKey, provider, model } }
);
return {
reply: result.reply || null,
intent: result.intent || null,
};
}
// ── Recall booking — actually reserves a slot, unlike the reschedule flow ──
// (which only MOVES an existing appointment). A recall patient has none, so
// this creates a new one directly on "Column A" (the office's first staff
// column, matching the convention already used in internal-chat-workflow.ts),
// in fixed 30-minute slots.
const RECALL_SLOT_MINUTES = 30;
function toMinutes(t: string): number {
const [h, m] = t.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
function fromMinutes(m: number): string {
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
/** "Column A" = the office's first staff record by id, per the same convention
* used for AI-booked appointments elsewhere (see internal-chat-workflow.ts). */
async function getColumnAStaffId(userId: number): Promise<number | null> {
const staff = await db.staff.findFirst({ where: { userId }, orderBy: { id: "asc" } });
return staff?.id ?? null;
}
/**
* Finds a free RECALL_SLOT_MINUTES slot for `staffId` on `date`. Prefers
* `requestedStartTime` if it's free; otherwise falls back to the earliest
* open slot that day. Returns null if the whole day is fully booked.
*/
async function findRecallSlot(
userId: number, staffId: number, date: Date, requestedStartTime?: string | null,
): Promise<{ time: string; matchedRequest: boolean } | null> {
const officeHours = await storage.getOfficeHours(userId);
const data = (officeHours as any)?.data;
const dayNames = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
const dayName = dayNames[date.getUTCDay()]!;
// Union of doctors' + hygienists' windows that day — matches the "open if
// within either group's hours" tolerance isWithinOfficeHours() already uses.
const windows: [number, number][] = [];
for (const group of [data?.doctors, data?.hygienists]) {
const slot = group?.[dayName];
if (slot?.enabled) {
windows.push([toMinutes(slot.amStart), toMinutes(slot.amEnd)]);
windows.push([toMinutes(slot.pmStart), toMinutes(slot.pmEnd)]);
}
}
if (windows.length === 0) {
windows.push([toMinutes("09:00"), toMinutes("12:00")], [toMinutes("13:00"), toMinutes("17:00")]);
}
const allSlots = new Set<number>();
for (const [start, end] of windows) {
for (let t = start; t + RECALL_SLOT_MINUTES <= end; t += RECALL_SLOT_MINUTES) allSlots.add(t);
}
const dateStr = date.toISOString().split("T")[0]!;
const existing = await storage.getAppointmentsByDateForUser(dateStr, userId);
const booked = existing
.filter((a: any) => a.staffId === staffId && a.status !== "cancelled")
.map((a: any) => ({ start: toMinutes(a.startTime), end: toMinutes(a.endTime) }));
const isFree = (slotStart: number) => {
const slotEnd = slotStart + RECALL_SLOT_MINUTES;
return !booked.some((b) => slotStart < b.end && slotEnd > b.start);
};
if (requestedStartTime) {
const reqStart = toMinutes(requestedStartTime);
if (allSlots.has(reqStart) && isFree(reqStart)) return { time: requestedStartTime, matchedRequest: true };
}
const sorted = Array.from(allSlots).sort((a, b) => a - b);
const fallback = sorted.find(isFree);
return fallback === undefined ? null : { time: fromMinutes(fallback), matchedRequest: false };
}
const DAY_FULL_FALLBACKS: Record<string, (dateLabel: string) => string> = {
English: (d) => `I'm sorry, ${d} is fully booked. Could you please choose another day?`,
Spanish: (d) => `Lo sentimos, el ${d} ya está completo. ¿Podría elegir otro día?`,
Portuguese: (d) => `Sinto muito, ${d} está totalmente reservado. Poderia escolher outro dia?`,
Mandarin: (d) => `很抱歉,${d} 的预约已满。您能选择其他日期吗?`,
Cantonese: (d) => `好抱歉,${d} 已經約滿。您可以選擇其他日子嗎?`,
Arabic: (d) => `عذراً، ${d} محجوز بالكامل. هل يمكنك اختيار يوم آخر؟`,
"Haitian Creole": (d) => `Padon, ${d} konplè. Èske ou ka chwazi yon lòt jou?`,
};
const NO_STAFF_FALLBACK: Record<string, string> = {
English: "Thanks! Our office staff will reach out shortly to confirm a time that works.",
Spanish: "¡Gracias! Nuestro personal se pondrá en contacto en breve para confirmar un horario.",
Portuguese: "Obrigado! Nossa equipe entrará em contato em breve para confirmar um horário.",
Mandarin: "谢谢!我们的工作人员将很快与您联系以确认时间。",
Cantonese: "多謝!我們的職員好快會聯絡您確認時間。",
Arabic: "شكراً! سيتواصل معك فريقنا قريباً لتأكيد الموعد.",
"Haitian Creole": "Mèsi! Ekip biwo nou an pral kontakte ou byento pou konfime yon lè.",
};
const BOOKED_EXACT_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
English: (d, t) => `You're all set for ${d} at ${t}! We look forward to seeing you.`,
Spanish: (d, t) => `¡Todo listo para el ${d} a las ${t}! Le esperamos.`,
Portuguese: (d, t) => `Tudo pronto para ${d} às ${t}! Aguardamos sua visita.`,
Mandarin: (d, t) => `已为您安排在 ${d} ${t}!期待您的光临。`,
Cantonese: (d, t) => `已幫您安排喺 ${d} ${t}!期待見到您。`,
Arabic: (d, t) => `تم الحجز في ${d} الساعة ${t}! نتطلع لرؤيتك.`,
"Haitian Creole": (d, t) => `Ou pare pou ${d} a ${t}! N'ap tann ou.`,
};
const BOOKED_ALT_FALLBACKS: Record<string, (dateLabel: string, time: string) => string> = {
English: (d, t) => `That exact time was just taken, so I booked you for ${t} on ${d} instead. We look forward to seeing you!`,
Spanish: (d, t) => `Esa hora ya no estaba disponible, así que le reservé a las ${t} el ${d}. ¡Le esperamos!`,
Portuguese: (d, t) => `Esse horário exato já foi reservado, então marquei ${t} em ${d}. Aguardamos sua visita!`,
Mandarin: (d, t) => `那个时间刚被预订了,我已为您安排在 ${d} ${t}。期待您的光临!`,
Cantonese: (d, t) => `嗰個時間啱啱被人訂咗,我已經幫您安排喺 ${d} ${t}。期待見到您!`,
Arabic: (d, t) => `تم حجز ذلك الوقت للتو، لذا حجزت لك موعداً في ${d} الساعة ${t}. نتطلع لرؤيتك!`,
"Haitian Creole": (d, t) => `Lè sa a fèk pran, kidonk mwen bwouke ou pou ${t} nan ${d}. N'ap tann ou!`,
};
const ASK_TIME_FALLBACKS: Record<string, (dateLabel: string) => string> = {
English: (d) => `Great, what time on ${d} works for you?`,
Spanish: (d) => `Genial, ¿qué hora le viene bien el ${d}?`,
Portuguese: (d) => `Ótimo, que horário funciona para você em ${d}?`,
Mandarin: (d) => `太好了,您 ${d} 哪个时间方便?`,
Cantonese: (d) => `太好喇,您 ${d} 邊個時間方便?`,
Arabic: (d) => `رائع، ما الوقت المناسب لك في ${d}؟`,
"Haitian Creole": (d) => `Bon, ki lè nan ${d} ki bon pou ou?`,
};
// Same follow-up question as ASK_TIME_FALLBACKS, without the leading "Great,"
// greeting — used when it directly follows a rejection sentence, where the
// greeting would read as redundant filler.
const ASK_TIME_PLAIN_FALLBACKS: Record<string, (dateLabel: string) => string> = {
English: (d) => `What time on ${d} works for you?`,
Spanish: (d) => `¿Qué hora le viene bien el ${d}?`,
Portuguese: (d) => `Que horário funciona para você em ${d}?`,
Mandarin: (d) => `您 ${d} 哪个时间方便?`,
Cantonese: (d) => `您 ${d} 邊個時間方便?`,
Arabic: (d) => `ما الوقت المناسب لك في ${d}؟`,
"Haitian Creole": (d) => `Ki lè nan ${d} ki bon pou ou?`,
};
const OFFICE_HOURS_ARE_FALLBACKS: Record<string, string> = {
English: "Our office hours are",
Spanish: "Nuestro horario de atención es",
Portuguese: "Nosso horário de atendimento é",
Mandarin: "我们的营业时间是",
Cantonese: "我們嘅營業時間係",
Arabic: "ساعات عملنا هي",
"Haitian Creole": "Orè biwo nou se",
};
const CLOSED_DAY_FALLBACKS: Record<string, (day: string) => string> = {
English: (day) => `We're closed on ${day}s. What other day works for you?`,
Spanish: (day) => `Estamos cerrados los ${day}. ¿Qué otro día le viene bien?`,
Portuguese: (day) => `Estamos fechados aos ${day}. Que outro dia funciona para você?`,
Mandarin: (day) => `我们${day}休息。您方便其他哪天?`,
Cantonese: (day) => `我們${day}休息。您方便其他邊日?`,
Arabic: (day) => `نحن مغلقون أيام ${day}. ما يوم آخر يناسبك؟`,
"Haitian Creole": (day) => `Nou fèmen jou ${day}. Ki lòt jou ki bon pou ou?`,
};
const NO_DATE_FALLBACKS: Record<string, string> = {
English: "Sorry, I didn't catch a day — what day would you like to come in?",
Spanish: "Disculpe, no entendí el día — ¿qué día le gustaría venir?",
Portuguese: "Desculpe, não entendi o dia — que dia você gostaria de vir?",
Mandarin: "抱歉,我没听清日期——您想哪天过来?",
Cantonese: "抱歉,我冇聽清楚日期——您想邊日嚟?",
Arabic: "عذراً، لم أفهم اليوم — ما اليوم الذي تود الحضور فيه؟",
"Haitian Creole": "Padon, mwen pa konprann jou a — ki jou ou ta renmen vini?",
};
/** Books the recall appointment (or reports the day is full) and returns the reply + next stage. */
async function bookRecallSlot(
patientId: number, userId: number, date: Date, dateLabel: string, requestedStartTime: string | null, lang: string,
): Promise<{ reply: string; nextStage: ConversationStage }> {
const staffId = await getColumnAStaffId(userId);
if (staffId === null) {
return { reply: NO_STAFF_FALLBACK[lang] ?? NO_STAFF_FALLBACK["English"]!, nextStage: "done" };
}
const slot = await findRecallSlot(userId, staffId, date, requestedStartTime);
if (!slot) {
const build = DAY_FULL_FALLBACKS[lang] ?? DAY_FULL_FALLBACKS["English"]!;
return { reply: build(dateLabel), nextStage: "recall_asked_datetime" };
}
const endTime = fromMinutes(toMinutes(slot.time) + RECALL_SLOT_MINUTES);
await storage.createAppointment({
patientId, userId, staffId,
title: "Routine Exam & Cleaning",
date, startTime: slot.time, endTime,
type: "recall",
status: "scheduled",
movedByAi: true,
} as any);
const timeLbl = timeLabel(slot.time);
const build = slot.matchedRequest
? (BOOKED_EXACT_FALLBACKS[lang] ?? BOOKED_EXACT_FALLBACKS["English"]!)
: (BOOKED_ALT_FALLBACKS[lang] ?? BOOKED_ALT_FALLBACKS["English"]!);
await sendAppointmentConfirmationSms(userId, patientId, dateLabel, timeLbl, lang);
return { reply: build(dateLabel, timeLbl), nextStage: "done" };
}
/**
* Drives the recall_asked_datetime / recall_asked_time_for_date stages:
* parses a date (and optionally a time) out of the patient's reply, checks
* office hours, then books via bookRecallSlot() once both pieces are known.
*/
export async function runRecallBookingStep(
message: string,
stage: ConversationStage,
language: string,
patientId: number,
userId: number,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string; nextStage: ConversationStage }> {
const lang = language || "English";
// ── Waiting on a time only (date was already captured last turn) ─────────
if (stage === "recall_asked_time_for_date") {
const pending = getPendingReschedule(userId, patientId);
if (!pending) {
return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" };
}
const time = await parseTime(message, apiKey, provider, model);
if (!time) {
const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!;
return { reply: build(pending.dayLabel), nextStage: "recall_asked_time_for_date" };
}
return await resolveDateTime(patientId, userId, pending.newDate, pending.dayLabel, time, lang);
}
// ── recall_initial (yes, with a date already in the same message) or
// recall_asked_datetime — parse a date out of the message ────────────────
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
if (!parsedDate) {
return { reply: NO_DATE_FALLBACKS[lang] ?? NO_DATE_FALLBACKS["English"]!, nextStage: "recall_asked_datetime" };
}
const dayCheck = await isOfficeDayOpen(parsedDate.date, userId);
if (!dayCheck.open) {
const build = CLOSED_DAY_FALLBACKS[lang] ?? CLOSED_DAY_FALLBACKS["English"]!;
return { reply: build(dayCheck.displayDay), nextStage: "recall_asked_datetime" };
}
const time = await parseTime(message, apiKey, provider, model);
if (!time) {
setPendingReschedule(userId, patientId, { newDate: parsedDate.date, dayLabel: parsedDate.dateLabel });
const build = ASK_TIME_FALLBACKS[lang] ?? ASK_TIME_FALLBACKS["English"]!;
return { reply: build(parsedDate.dateLabel), nextStage: "recall_asked_time_for_date" };
}
return await resolveDateTime(patientId, userId, parsedDate.date, parsedDate.dateLabel, time, lang);
}
async function resolveDateTime(
patientId: number, userId: number, date: Date, dateLabel: string, time: string, lang: string,
): Promise<{ reply: string; nextStage: ConversationStage }> {
const withinHours = await isWithinOfficeHours(date, time, userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(date, userId);
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
const base: Record<string, string> = {
English: `We're not available at that time on ${dateLabel}.`,
Spanish: `No estamos disponibles a esa hora el ${dateLabel}.`,
Portuguese: `Não estamos disponíveis nesse horário em ${dateLabel}.`,
Mandarin: `我们在 ${dateLabel} 的那个时间不营业。`,
Cantonese: `我們喺 ${dateLabel} 嗰個時間唔開放。`,
Arabic: `نحن غير متاحين في ذلك الوقت يوم ${dateLabel}.`,
"Haitian Creole": `Nou pa disponib nan lè sa a nan ${dateLabel}.`,
};
const askTime = (ASK_TIME_PLAIN_FALLBACKS[lang] ?? ASK_TIME_PLAIN_FALLBACKS["English"]!)(dateLabel);
const officeHoursAre = OFFICE_HOURS_ARE_FALLBACKS[lang] ?? OFFICE_HOURS_ARE_FALLBACKS["English"]!;
const hours = hoursDisplay ? ` ${officeHoursAre} ${hoursDisplay}.` : "";
return { reply: `${base[lang] ?? base["English"]}${hours} ${askTime}`, nextStage: "recall_asked_time_for_date" };
}
clearPendingReschedule(userId, patientId);
return await bookRecallSlot(patientId, userId, date, dateLabel, time, lang);
}
+24 -3
View File
@@ -7,6 +7,7 @@ import {
getPendingReschedule, getPendingReschedule,
clearPendingReschedule, clearPendingReschedule,
} from "./aiHandoffStore"; } from "./aiHandoffStore";
import { sendAppointmentConfirmationSms } from "./confirmationSms";
// ── Date helpers ────────────────────────────────────────────────────────────── // ── Date helpers ──────────────────────────────────────────────────────────────
@@ -392,18 +393,35 @@ async function isSlotAvailable(
// ── Time parsing (legacy) ───────────────────────────────────────────────────── // ── Time parsing (legacy) ─────────────────────────────────────────────────────
export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> { export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> {
const t = message.toLowerCase(); // Twilio speech-to-text usually renders "pm"/"am" with periods ("p.m."),
// and often drops the colon in a spoken time too ("1:00pm" -> "100 pm",
// "12:30pm" -> "1230 pm") — normalize both before matching.
const t = message.toLowerCase()
.replace(/\ba\.m\.?/g, "am")
.replace(/\bp\.m\.?/g, "pm");
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00"; if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00"; if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
const ampm = t.match(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/); const ampm = t.match(/\b(\d{1,2}):([0-5]\d)\s*(am|pm)\b/);
const glued = !ampm ? t.match(/\b(\d{1,4})\s*(am|pm)\b/) : null;
const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); const clock = t.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
if (ampm) { if (ampm) {
let h = parseInt(ampm[1]!); let h = parseInt(ampm[1]!);
const m = ampm[2] ? parseInt(ampm[2]) : 0; const m = parseInt(ampm[2]!);
if (ampm[3] === "pm" && h < 12) h += 12; if (ampm[3] === "pm" && h < 12) h += 12;
if (ampm[3] === "am" && h === 12) h = 0; if (ampm[3] === "am" && h === 12) h = 0;
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`; return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
} }
if (glued) {
const digits = glued[1]!;
let h: number, m: number;
if (digits.length <= 2) { h = parseInt(digits); m = 0; }
else { h = parseInt(digits.slice(0, -2)); m = parseInt(digits.slice(-2)); }
if (h >= 1 && h <= 12 && m <= 59) {
if (glued[2] === "pm" && h < 12) h += 12;
if (glued[2] === "am" && h === 12) h = 0;
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`;
}
}
if (clock) return clock[0]!; if (clock) return clock[0]!;
try { try {
const llm = getLlm(provider, apiKey, model); const llm = getLlm(provider, apiKey, model);
@@ -820,6 +838,8 @@ export async function runRescheduleStep(
fallback, fallback,
apiKey, provider, model apiKey, provider, model
); );
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
await sendAppointmentConfirmationSms(userId, patientId, datePart, timeLabel(startTime), lang);
return { reply, nextStage: "done" }; return { reply, nextStage: "done" };
} }
@@ -1109,6 +1129,7 @@ export async function runRescheduleStep(
`You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`, `You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`,
`Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model `Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model
); );
await sendAppointmentConfirmationSms(userId, patientId, pending.dayLabel, timeLabel(startTime), lang);
return { reply, nextStage: "done" }; return { reply, nextStage: "done" };
} }
+6
View File
@@ -0,0 +1,6 @@
const CLOSING_PATTERNS =
/\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i;
export function soundsLikeGoodbye(text: string): boolean {
return CLOSING_PATTERNS.test(text.trim());
}
@@ -102,7 +102,7 @@ export async function runCCAPreAuthProcessor(
// Save authNumber into the claim record (preauth no column) // Save authNumber into the claim record (preauth no column)
if (claimId && authNumber) { if (claimId && authNumber) {
try { try {
await storage.updateClaim(claimId, { claimNumber: authNumber, status: "APPROVED" } as any); await storage.updateClaim(claimId, { claimNumber: authNumber, status: "PREAUTH" } as any);
log("cca-preauth-processor", "authNumber saved to claim", { claimId, authNumber }); log("cca-preauth-processor", "authNumber saved to claim", { claimId, authNumber });
} catch (e: any) { } catch (e: any) {
log("cca-preauth-processor", "failed to save authNumber to claim", { error: e?.message }); log("cca-preauth-processor", "failed to save authNumber to claim", { error: e?.message });
@@ -117,13 +117,15 @@ export async function runCCAPreAuthProcessor(
const pdfBuffer = Buffer.from(pdfBase64, "base64"); const pdfBuffer = Buffer.from(pdfBase64, "base64");
const patient = await storage.getPatient(patientId); const patient = await storage.getPatient(patientId);
const patientName = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : ""; const patientName = patient ? `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() : "";
const mimeType = pdfFilename.toLowerCase().endsWith(".png") ? "image/png" : "application/pdf";
const cloudFile = await storage.savePdfToCloudStorage( const cloudFile = await storage.savePdfToCloudStorage(
userId, userId,
patientId, patientId,
patientName || `Patient ${patientId}`, patientName || `Patient ${patientId}`,
"PreAuth", "PreAuth",
pdfFilename, pdfFilename,
pdfBuffer pdfBuffer,
mimeType
); );
pdfFileId = `cloud:${cloudFile.id}`; pdfFileId = `cloud:${cloudFile.id}`;
log("cca-preauth-processor", "PDF saved", { pdfFilename, pdfFileId, patientId }); log("cca-preauth-processor", "PDF saved", { pdfFilename, pdfFileId, patientId });
@@ -34,6 +34,8 @@ export interface CmspEligibilityHistoryRemainingProcessorResult {
historyPdfFilename?: string | null; historyPdfFilename?: string | null;
accumulatorPdfFileId?: number | string | null; accumulatorPdfFileId?: number | string | null;
accumulatorPdfFilename?: string | null; accumulatorPdfFilename?: string | null;
remainingBenefit?: string | null;
thirdPartyInsurance?: string | null;
} }
export async function runCmspEligibilityHistoryRemainingProcessor( export async function runCmspEligibilityHistoryRemainingProcessor(
@@ -105,8 +107,21 @@ export async function runCmspEligibilityHistoryRemainingProcessor(
const updates: any = { status: newStatus }; const updates: any = { status: newStatus };
if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance; if (seleniumInsurance) updates.insuranceProvider = seleniumInsurance;
const remainingBenefit = seleniumResult.remaining_amount?.trim() || null;
if (remainingBenefit) updates.remainingBenefit = remainingBenefit;
// third_party_insurance is present (even as null) whenever the Third Party
// Liability table was found — null means the member has no 2nd insurance,
// so we still write it to clear any stale value from a prior check.
if (Object.prototype.hasOwnProperty.call(seleniumResult, "third_party_insurance")) {
updates.thirdPartyInsurance = seleniumResult.third_party_insurance?.trim() || null;
}
await storage.updatePatient(patient.id, updates); await storage.updatePatient(patient.id, updates);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
outputResult.remainingBenefit = remainingBenefit;
outputResult.thirdPartyInsurance = updates.thirdPartyInsurance ?? null;
// Helper: save a single PDF to the patient's Cloud Storage Eligibility folder // Helper: save a single PDF to the patient's Cloud Storage Eligibility folder
const patientName = `${patient!.firstName ?? ""} ${patient!.lastName ?? ""}`.trim() || `Patient ${patient!.id}`; const patientName = `${patient!.firstName ?? ""} ${patient!.lastName ?? ""}`.trim() || `Patient ${patient!.id}`;
@@ -138,8 +138,9 @@ async function processDdmaResult(
// 4) Determine and update eligibility status + insurance provider name // 4) Determine and update eligibility status + insurance provider name
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase(); const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
const newStatus = let newStatus = "UNKNOWN";
eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE"; if (eligStatus === "active" || eligStatus === "y") newStatus = "ACTIVE";
else if (eligStatus === "inactive" || eligStatus === "n" || eligStatus === "termed") newStatus = "INACTIVE";
await storage.updatePatient(patient.id, { await storage.updatePatient(patient.id, {
status: newStatus, status: newStatus,
insuranceProvider: "Delta Dental MA", insuranceProvider: "Delta Dental MA",
@@ -30,6 +30,7 @@ export interface EligibilityProcessorResult {
patientUpdateStatus?: string; patientUpdateStatus?: string;
pdfUploadStatus?: string; pdfUploadStatus?: string;
pdfFileId?: number | string | null; pdfFileId?: number | string | null;
patientId?: number | null;
} }
export async function runEligibilityProcessor( export async function runEligibilityProcessor(
@@ -109,6 +110,8 @@ export async function runEligibilityProcessor(
throw new Error(`Failed to create/update patient: ${e.message}`); throw new Error(`Failed to create/update patient: ${e.message}`);
} }
outputResult.patientId = patient?.id ?? null;
// 5) Update patient status // 5) Update patient status
if (patient && patient.id !== undefined) { if (patient && patient.id !== undefined) {
let newStatus = "UNKNOWN"; let newStatus = "UNKNOWN";
@@ -121,13 +124,24 @@ export async function runEligibilityProcessor(
await storage.updatePatient(patient.id, updates); await storage.updatePatient(patient.id, updates);
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`; outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
// Rename the Copy Agent page's temp folder ("PID-<id>") to the real name now that we know
// it — only touches folders still using that placeholder pattern, never a normal patient's.
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
try {
const existingFolder = await storage.getPatientFolder(patient.id);
if (existingFolder && /^PID-\d+$/.test(existingFolder.name) && patientName) {
await storage.updateFolder((existingFolder as any).id, { name: patientName });
}
} catch (e) {
console.error("[eligibilityProcessor] temp folder rename failed:", e);
}
// 6) Save PDF // 6) Save PDF
let createdPdfFileId: number | string | null = null; let createdPdfFileId: number | string | null = null;
if (seleniumResult.pdf_path?.endsWith(".pdf")) { if (seleniumResult.pdf_path?.endsWith(".pdf")) {
try { try {
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path); const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim() || `Patient ${patient.id}`;
const cloudFile = await storage.savePdfToCloudStorage( const cloudFile = await storage.savePdfToCloudStorage(
userId, userId,
+114 -1
View File
@@ -2,10 +2,15 @@ import express, { Request, Response } from "express";
import fs from "fs"; import fs from "fs";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import multer from "multer";
import { storage } from "../storage"; import { storage } from "../storage";
import { classifyInternalChat } from "../ai/internal-chat-graph"; import { classifyInternalChat } from "../ai/internal-chat-graph";
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow"; import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
import { resolveAiProvider } from "../ai/llm-factory"; import { resolveAiProvider, getLlm } from "../ai/llm-factory";
import { backupScreenshots } from "../utils/screenshotBackup";
const eligibilityImageUpload = multer({ storage: multer.memoryStorage() });
const ELIGIBILITY_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/jpg"]);
const CHAT_HISTORY_DIR = path.join(__dirname, "..", "..", "chat-history"); const CHAT_HISTORY_DIR = path.join(__dirname, "..", "..", "chat-history");
@@ -218,6 +223,32 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
} }
}); });
// GET /api/ai/call-templates
router.get("/call-templates", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const templates = await storage.getAiCallTemplates(userId);
return res.status(200).json(templates);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch AI call templates", details: String(err) });
}
});
// PUT /api/ai/call-templates
router.put("/call-templates", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting } = req.body;
await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting });
const updated = await storage.getAiCallTemplates(userId);
return res.status(200).json(updated);
} catch (err) {
return res.status(500).json({ error: "Failed to save AI call templates", details: String(err) });
}
});
// GET /api/ai/internal-chat-settings // GET /api/ai/internal-chat-settings
router.get("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => { router.get("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => {
try { try {
@@ -336,6 +367,88 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
} }
}); });
// POST /api/ai/detect-eligibility-info
// Sends screenshot(s) to the configured vision-capable AI to extract a Member ID and DOB,
// used by the Copy Agent page's "AI Detect & Check Eligibility" action.
router.post(
"/detect-eligibility-info",
eligibilityImageUpload.array("files", 10),
async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const files = req.files as Express.Multer.File[] | undefined;
if (!files?.length) return res.status(400).json({ error: "No files uploaded" });
const badFile = files.find((f) => !ELIGIBILITY_IMAGE_MIMES.has(f.mimetype.toLowerCase()));
if (badFile) {
return res.status(400).json({ error: `Unsupported file type: ${badFile.mimetype}` });
}
backupScreenshots(files);
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
return res.status(200).json({ error: true, message: "AI is not configured. Please add an API key in AI Settings." });
}
if (activeAi.provider !== "claude") {
return res.status(200).json({ error: true, message: "Screenshot detection requires Claude to be the active AI provider." });
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content: Array<Record<string, unknown>> = [
{
type: "text",
text:
"Extract the Member ID and Date of Birth from this dental/insurance eligibility screenshot. " +
"Also read any visible insurance payer/plan name text (e.g. \"Sun Life\", \"DentaQuest\", \"MassHealth\") exactly as shown. " +
'Respond with strict JSON only, no prose, no markdown fences: {"memberId": string|null, "dob": "YYYY-MM-DD"|null, "insuranceProvider": string|null}',
},
...files.map((f) => ({
type: "image_url",
image_url: { url: `data:${f.mimetype};base64,${f.buffer.toString("base64")}` },
})),
];
const response = await llm.invoke([{ role: "user", content }] as any);
let memberId: string | null = null;
let dob: string | null = null;
let insuranceProvider: string | null = null;
try {
const raw = String(response.content).trim();
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as { memberId?: string | null; dob?: string | null; insuranceProvider?: string | null };
// Sanitize once here so this is the single source of truth for the memberId
// used downstream (temp-patient creation, chatbot prefill, real eligibility check) —
// any mismatch in formatting would cause createOrUpdatePatientByInsuranceId to create
// a second patient instead of matching the temp one.
const cleanedMemberId = (parsed.memberId || "").replace(/[^A-Za-z0-9]/g, "");
memberId = cleanedMemberId || null;
dob = parsed.dob || null;
insuranceProvider = parsed.insuranceProvider || null;
} catch (parseErr) {
console.error("[detect-eligibility-info] failed to parse AI response", parseErr);
}
// Sun Life now brands the DentaQuest/Tufts SCO portal — screenshots showing both
// names together mean the Tufts SCO auto-check, not the MassHealth default.
const providerText = (insuranceProvider || "").toLowerCase();
const hasSunLife = /sun\s*life/.test(providerText);
const hasDentaQuest = /denta\s*quest/.test(providerText);
const autoCheck = hasSunLife && hasDentaQuest ? "tufts-sco" : "mh";
return res.status(200).json({ error: false, data: { memberId, dob, insuranceProvider, autoCheck } });
} catch (err) {
console.error("[detect-eligibility-info]", err);
return res.status(500).json({ error: true, message: "Failed to detect eligibility info", details: String(err) });
}
}
);
router.post("/create-appointment-today", async (req: Request, res: Response): Promise<any> => { router.post("/create-appointment-today", async (req: Request, res: Response): Promise<any> => {
try { try {
const userId = req.user?.id; const userId = req.user?.id;
+126
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { storage } from "../storage"; import { storage } from "../storage";
import { z } from "zod"; import { z } from "zod";
import multer from "multer";
import { import {
insertAppointmentSchema, insertAppointmentSchema,
updateAppointmentSchema, updateAppointmentSchema,
@@ -9,6 +10,20 @@ import {
const router = Router(); const router = Router();
// Reuses the same memory-storage + type/size limits as claims.ts's upload-to-cloud route
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
fileFilter: (req, file, cb) => {
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Unsupported file type"));
}
},
});
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts // Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
function inferApptType(codes: string[]): string | null { function inferApptType(codes: string[]): string | null {
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"]; const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
@@ -413,6 +428,7 @@ router.put(
if (rawTypeLocked !== undefined) updatePayload.typeLocked = Boolean(rawTypeLocked); if (rawTypeLocked !== undefined) updatePayload.typeLocked = Boolean(rawTypeLocked);
if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status; if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status;
if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes; if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes;
if ("npiProviderId" in appointmentData) updatePayload.npiProviderId = appointmentData.npiProviderId ?? null;
if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN"; if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN";
// Update appointment // Update appointment
@@ -473,6 +489,116 @@ router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> =
} }
}); });
// List attachments for an appointment
router.get(
"/:appointmentId/files",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
if (isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointment ID" });
}
try {
const files = await storage.getAppointmentFiles(appointmentId);
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to load attachments", message: err?.message });
}
}
);
// Add a single attachment to an appointment (incremental insert, does not touch existing files)
router.post(
"/:appointmentId/files",
upload.single("file"),
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentIdParam = req.params.appointmentId;
const appointmentId = parseInt(appointmentIdParam || "", 10);
if (isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointment ID" });
}
const file = req.file;
if (!file) return res.status(400).json({ error: "No file uploaded" });
try {
const appointment = await storage.getAppointment(appointmentId);
if (!appointment) {
return res.status(404).json({ error: "Appointment not found" });
}
const patient = await storage.getPatient(appointment.patientId);
if (!patient) {
return res.status(404).json({ error: "Patient not found" });
}
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`
.trim()
.replace(/[/\\?%*:|"<>]/g, "-") || "unknown";
const folder = await storage.getOrCreatePatientFolder(
req.user.id,
appointment.patientId,
patientName
);
const attachmentsFolder = await storage.getOrCreateSubfolder(
req.user.id,
(folder as any).id,
"Attachments"
);
const cloudFile = await storage.initializeFileUpload(
req.user.id,
file.originalname,
file.mimetype,
BigInt(file.size),
1,
(attachmentsFolder as any).id
);
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
const finalized = await storage.finalizeFileUpload((cloudFile as any).id);
const created = await storage.addAppointmentFile(appointmentId, {
filename: file.originalname,
mimeType: file.mimetype,
filePath: finalized.diskPath,
});
return res.json({ error: false, data: created });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to upload file", message: err?.message });
}
}
);
// Delete a single attachment from an appointment
router.delete(
"/:appointmentId/files/:fileId",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
const fileId = parseInt(req.params.fileId || "", 10);
if (isNaN(appointmentId) || isNaN(fileId)) {
return res.status(400).json({ error: "Invalid appointment or file ID" });
}
try {
const deleted = await storage.deleteAppointmentFile(appointmentId, fileId);
if (!deleted) return res.status(404).json({ error: "Attachment not found" });
return res.json({ error: false });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to delete attachment", message: err?.message });
}
}
);
// Delete an appointment // Delete an appointment
router.delete("/:id", async (req: Request, res: Response): Promise<any> => { router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try { try {
+133 -13
View File
@@ -19,6 +19,7 @@ import {
} from "@repo/db/types"; } from "@repo/db/types";
import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient"; import { forwardToSeleniumClaimPreAuthAgent } from "../services/seleniumInsuranceClaimPreAuthClient";
import { formatDobForAgent } from "../utils/dateUtils"; import { formatDobForAgent } from "../utils/dateUtils";
import { backupScreenshots } from "../utils/screenshotBackup";
const router = Router(); const router = Router();
@@ -106,13 +107,22 @@ router.post(
const patientId = Number(req.body.patientId); const patientId = Number(req.body.patientId);
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim(); const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
const pending = String(req.body.pending || "") === "true";
const isScreenshotUpload = String(req.body.screenshotBackup || "") === "true";
if (!patientId || isNaN(patientId)) { if (!patientId || isNaN(patientId)) {
return res.status(400).json({ error: "Invalid patientId" }); return res.status(400).json({ error: "Invalid patientId" });
} }
if (isScreenshotUpload) backupScreenshots(files);
try { try {
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, patientName); let folderName = patientName;
if (pending) {
const existingFolder = await storage.getPatientFolder(patientId);
if (!existingFolder) folderName = "Pending Review";
}
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, folderName);
const attachmentsFolder = await storage.getOrCreateSubfolder( const attachmentsFolder = await storage.getOrCreateSubfolder(
req.user.id, req.user.id,
(folder as any).id, (folder as any).id,
@@ -158,6 +168,76 @@ router.post(
} }
); );
// PATCH /api/claims/patient-folder/:patientId/confirm-name
// Renames a patient's Cloud Storage folder from its placeholder ("Pending Review") to the
// real patient name once the user has confirmed the attachments belong to that patient.
router.patch(
"/patient-folder/:patientId/confirm-name",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const patientId = Number(req.params.patientId);
const patientName = String(req.body.patientName || "").replace(/[/\\?%*:|"<>]/g, "-").trim();
if (!patientId || isNaN(patientId) || !patientName) {
return res.status(400).json({ error: "Invalid patientId or patientName" });
}
try {
const folder = await storage.getPatientFolder(patientId);
if (!folder) return res.status(404).json({ error: "Patient folder not found" });
await storage.updateFolder((folder as any).id, { name: patientName });
return res.json({ error: false });
} catch (err: any) {
console.error("[confirm-name]", err);
return res.status(500).json({ error: "Failed to rename patient folder", message: err?.message });
}
}
);
// GET /api/claims/patient-attachments?patientId=123
// Lists this patient's previously saved Cloud Storage "Attachments" (e.g. Copy Agent screenshots)
// so they can be picked and re-attached to a claim/preauth without re-uploading from disk.
router.get(
"/patient-attachments",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const patientId = Number(req.query.patientId);
if (!patientId || isNaN(patientId)) {
return res.status(400).json({ error: "Invalid patientId" });
}
try {
const files = await storage.listPatientCategoryCloudFiles(patientId, "Attachments");
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[patient-attachments]", err);
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
}
}
);
// GET /api/claims/recent-attachments?limit=20
// Lists recently saved Cloud Storage "Attachments" across all patients (e.g. Copy Agent
// screenshots), for use in contexts (like the AI chatbot) that don't have a patient in scope yet.
router.get(
"/recent-attachments",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const limit = Number(req.query.limit) || 20;
try {
const files = await storage.listRecentAttachmentFiles(limit);
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[recent-attachments]", err);
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
}
}
);
router.post( router.post(
"/mh-provider-login", "/mh-provider-login",
async (req: Request, res: Response): Promise<any> => { async (req: Request, res: Response): Promise<any> => {
@@ -266,6 +346,26 @@ router.post(
mimetype: f.mimetype, 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.
// 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);
}
}
const job = await seleniumQueue.add("claim-submit", { const job = await seleniumQueue.add("claim-submit", {
jobType: "claim-submit", jobType: "claim-submit",
userId: req.user.id, userId: req.user.id,
@@ -660,14 +760,13 @@ router.post(
const npiProviders = await storage.getNpiProvidersByUser(req.user.id); const npiProviders = await storage.getNpiProvidersByUser(req.user.id);
const npiProvider = npiProviders[0] ?? null; const npiProvider = npiProviders[0] ?? null;
// procNpiProviderId = user's explicit choice saved via "Select Procedures" form // procNpiProviderId = user's explicit choice saved via "Select Procedures" form (legacy manual flow)
// This ALWAYS wins over any previously stored claim npiProviderId
const procNpiProviderId = (apptProcedures as any[]).find((p) => p.npiProviderId)?.npiProviderId ?? null; const procNpiProviderId = (apptProcedures as any[]).find((p) => p.npiProviderId)?.npiProviderId ?? null;
// Priority: Select Procedures choice > existing claim > first provider // Priority: appointment's Provider field (set in Edit Appointment) > Select Procedures choice > existing claim > first provider
const claimNpiProviderId = procNpiProviderId ?? activeClaim?.npiProviderId ?? npiProvider?.id ?? null; const claimNpiProviderId = (apt as any).npiProviderId ?? procNpiProviderId ?? activeClaim?.npiProviderId ?? npiProvider?.id ?? null;
console.log(`[batch-column] apt=${apt.id} siteKey=${siteKey} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`); console.log(`[batch-column] apt=${apt.id} siteKey=${siteKey} aptNpiId=${(apt as any).npiProviderId} procNpiId=${procNpiProviderId} claimNpiId=${activeClaim?.npiProviderId} resolved=${claimNpiProviderId}`);
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(); const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
@@ -675,8 +774,8 @@ router.post(
let claimId: number; let claimId: number;
if (activeClaim?.id) { if (activeClaim?.id) {
claimId = activeClaim.id; claimId = activeClaim.id;
if (procNpiProviderId && activeClaim.npiProviderId !== procNpiProviderId) { if (claimNpiProviderId && activeClaim.npiProviderId !== claimNpiProviderId) {
await storage.updateClaim(claimId, { npiProviderId: procNpiProviderId }); await storage.updateClaim(claimId, { npiProviderId: claimNpiProviderId });
} }
} else { } else {
// Validate required integer fields before sending to Prisma // Validate required integer fields before sending to Prisma
@@ -763,7 +862,11 @@ router.post(
// straight from disk via filePath (unlike MH/CCA, which get base64 buffers above). // straight from disk via filePath (unlike MH/CCA, which get base64 buffers above).
const diskClaimFiles = allFileMeta.filter((f) => { const diskClaimFiles = allFileMeta.filter((f) => {
if (!f.filePath) return false; if (!f.filePath) return false;
return fs.existsSync(path.join(process.cwd(), f.filePath)); const exists = fs.existsSync(path.join(process.cwd(), f.filePath));
if (!exists) {
console.warn(`[batch-column] attachment not found on disk: ${path.join(process.cwd(), f.filePath)}`);
}
return exists;
}); });
// Base claim data shared across all insurance pathways // Base claim data shared across all insurance pathways
@@ -1300,15 +1403,32 @@ router.put("/:id", async (req: Request, res: Response): Promise<any> => {
await syncAppointmentType(existingClaim.appointmentId, updatedCodes); await syncAppointmentType(existingClaim.appointmentId, updatedCodes);
} }
// If new claimFiles are provided, append only ones not already attached to this claim // If claimFiles are provided, sync ClaimFile rows to match the submitted list exactly:
if (Array.isArray(req.body.claimFiles) && req.body.claimFiles.length > 0) { // delete rows no longer present (so removing an attachment in the UI actually persists)
// and add rows not yet attached. Any AppointmentFile still on the appointment gets
// re-linked right after by autoLinkAppointmentFiles, so current attachments aren't lost.
if (Array.isArray(req.body.claimFiles)) {
const { prisma: db } = await import("@repo/db/client"); const { prisma: db } = await import("@repo/db/client");
const existingFiles = await db.claimFile.findMany({ const existingFiles = await db.claimFile.findMany({
where: { claimId }, where: { claimId },
select: { filename: true, filePath: true }, select: { id: true, filename: true, filePath: true },
}); });
const submittedKeys = new Set(
req.body.claimFiles.map(
(f: any) => `${String(f.filename || "")}::${String(f.filePath || "")}`
)
);
const staleIds = existingFiles
.filter((f: any) => !submittedKeys.has(`${f.filename}::${f.filePath ?? ""}`))
.map((f: any) => f.id);
if (staleIds.length > 0) {
await db.claimFile.deleteMany({ where: { id: { in: staleIds } } });
}
const existingKeys = new Set( const existingKeys = new Set(
existingFiles.map((f: any) => `${f.filename}::${f.filePath ?? ""}`) existingFiles
.filter((f: any) => !staleIds.includes(f.id))
.map((f: any) => `${f.filename}::${f.filePath ?? ""}`)
); );
const seenKeys = new Set<string>(); const seenKeys = new Set<string>();
const newFiles = req.body.claimFiles.filter((f: any) => { const newFiles = req.body.claimFiles.filter((f: any) => {
+2
View File
@@ -45,6 +45,7 @@ import licenseRoutes from "./license";
import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa"; import insuranceStatusBcbsMaRoutes from "./insuranceStatusBcbsMa";
import labRxRoutes from "./lab-rx"; import labRxRoutes from "./lab-rx";
import seleniumSettingsRoutes from "./selenium-settings"; import seleniumSettingsRoutes from "./selenium-settings";
import typeAgentRoutes from "./type-agent";
const router = Router(); const router = Router();
@@ -82,6 +83,7 @@ router.use("/cloud-storage", cloudStorageRoutes);
router.use("/payments-reports", paymentsReportsRoutes); router.use("/payments-reports", paymentsReportsRoutes);
router.use("/export-payments-reports", exportPaymentsReportsRoutes); router.use("/export-payments-reports", exportPaymentsReportsRoutes);
router.use("/job-monitor", jobMonitorRoutes); router.use("/job-monitor", jobMonitorRoutes);
router.use("/type-agent", typeAgentRoutes);
router.use("/twilio", twilioRoutes); router.use("/twilio", twilioRoutes);
router.use("/ai", aiSettingsRoutes); router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes); router.use("/office-hours", officeHoursRoutes);
+33
View File
@@ -4,6 +4,7 @@ import { storage } from "../storage";
import { z } from "zod"; import { z } from "zod";
import { insertPatientSchema, updatePatientSchema } from "@repo/db/types"; import { insertPatientSchema, updatePatientSchema } from "@repo/db/types";
import { normalizeInsuranceId } from "../utils/helpers"; import { normalizeInsuranceId } from "../utils/helpers";
import { createOrUpdatePatientByInsuranceId } from "../queue/processors/_shared";
const router = Router(); const router = Router();
@@ -190,6 +191,38 @@ router.get(
} }
); );
// POST /api/patients/create-temp
// Creates (or reuses) a lightweight placeholder patient — "PID-<id>" — from a Member ID/DOB
// read off a screenshot by the Copy Agent page's AI Detect flow, before the real name is known.
// Uses the same memberId+dob matching the actual eligibility check will later use, so that
// check automatically rewrites this patient's real name once it runs (see eligibilityProcessor.ts).
router.post("/create-temp", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).json({ message: "Unauthorized" });
const memberId = String(req.body.memberId || "").trim();
const dob = req.body.dob;
if (!memberId || !dob) {
return res.status(400).json({ message: "memberId and dob are required" });
}
let patient = await createOrUpdatePatientByInsuranceId({
insuranceId: memberId,
dob,
userId: req.user.id,
});
if (patient && patient.id !== undefined && !patient.firstName) {
await storage.updatePatient(patient.id, { firstName: `PID-${patient.id}` });
patient = (await storage.getPatient(patient.id)) ?? patient;
}
return res.status(200).json({ error: false, data: patient });
} catch (error: any) {
return res.status(500).json({ message: "Failed to create temp patient", details: error?.message });
}
});
// Create a new patient // Create a new patient
router.post("/", async (req: Request, res: Response): Promise<any> => { router.post("/", async (req: Request, res: Response): Promise<any> => {
try { try {
File diff suppressed because it is too large Load Diff
+49
View File
@@ -3,6 +3,8 @@ import twilio from "twilio";
import { storage } from "../storage"; import { storage } from "../storage";
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore"; import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
import { resolveAiProvider } from "../ai/llm-factory";
import { getTwilioPublicBaseUrl } from "../utils/publicUrl";
const router = express.Router(); const router = express.Router();
@@ -117,6 +119,53 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
} }
}); });
// POST /api/twilio/make-ai-call
// Places an outbound call where Lisa (the AI assistant) conducts the whole
// conversation live over the phone — see /webhook/ai-voice for the loop.
router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { to, patientId, purpose } = req.body;
if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" });
const settings = await storage.getTwilioSettings(userId);
if (!settings) {
return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
}
const aiSettings = await storage.getAiSettings(userId);
if (!resolveAiProvider(aiSettings ?? {})) {
return res.status(400).json({ message: "AI is not configured. Please add an AI provider key in Settings before placing an AI call." });
}
const patient = await db.patient.findFirst({ where: { id: Number(patientId), userId } });
if (!patient) return res.status(404).json({ message: "Patient not found" });
let publicBaseUrl: string;
try {
publicBaseUrl = getTwilioPublicBaseUrl();
} catch (err: any) {
return res.status(500).json({ message: err.message });
}
const callPurpose = purpose === "recall" ? "recall" : "reminder";
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`;
const client = getTwilioClient(settings.accountSid, settings.authToken);
const call = await client.calls.create({
url: webhookUrl,
method: "POST",
from: settings.phoneNumber,
to,
});
return res.status(200).json({ sid: call.sid, status: call.status });
} catch (err: any) {
return res.status(500).json({ error: err.message || "Failed to place AI call" });
}
});
// POST /api/twilio/send-reminders-batch // POST /api/twilio/send-reminders-batch
router.post("/send-reminders-batch", async (req: Request, res: Response): Promise<any> => { router.post("/send-reminders-batch", async (req: Request, res: Response): Promise<any> => {
try { try {
+110
View File
@@ -0,0 +1,110 @@
import crypto from "crypto";
import { Router, Request, Response } from "express";
import { getAgentStatus, io } from "../socket";
import { captureScreenshot, click, disconnectAgent, doubleClick, doubleClickCurrent, pressKey, typeText } from "../services/windowsAgentBridge";
import { runExistingPatientOpenDental, runExistingPatientOpenDentalRatioTest } from "../services/typeAgentRunner";
import { storage } from "../storage";
const router = Router();
// GET /api/type-agent/status
// Current Windows Agent (DentalAgent.exe) connection status, for the initial page load —
// live updates after that come over the "type-agent:status" socket.io event.
router.get("/status", (_req: Request, res: Response) => {
res.json(getAgentStatus());
});
// POST /api/type-agent/disconnect
// Forcibly disconnects a connected agent by IP — the "x" on the Type Agent page.
router.post("/disconnect", (req: Request, res: Response) => {
const { ip } = req.body ?? {};
if (!ip) {
res.status(400).json({ error: "ip is required" });
return;
}
try {
disconnectAgent(ip);
res.json({ success: true });
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : "Agent not found" });
}
});
// POST /api/type-agent/test-command
// Sends a single low-level command (screenshot/click/double_click/type/key) straight to a
// connected Windows Agent. Exists to verify the command bridge before the AI-driven step
// orchestration is wired in — not meant to be called from the Type Agent UI directly.
router.post("/test-command", async (req: Request, res: Response) => {
const { type, x, y, text, key, ip } = req.body ?? {};
try {
let result;
switch (type) {
case "screenshot":
result = await captureScreenshot(ip);
break;
case "click":
result = await click(Number(x), Number(y), ip);
break;
case "double_click":
result = await doubleClick(Number(x), Number(y), ip);
break;
case "double_click_current":
result = await doubleClickCurrent(ip);
break;
case "type":
result = await typeText(String(text ?? ""), ip);
break;
case "key":
result = await pressKey(String(key ?? ""), ip);
break;
default:
res.status(400).json({ error: "Unknown command type" });
return;
}
res.json({ success: true, result });
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Command failed" });
}
});
// POST /api/type-agent/run
// Kicks off a real step-by-step run against a connected Windows Agent. Only Open Dental /
// existing-patient is wired up so far — everything else the Type Agent page still shows as
// a preview only. Responds immediately with a runId; progress streams over the
// "type-agent:run:<runId>" socket.io event so the browser doesn't have to hold the request
// open for what can be a slow, multi-step, vision-driven sequence.
router.post("/run", async (req: Request, res: Response) => {
const userId = req.user?.id;
if (!userId) {
res.status(401).json({ error: "Unauthorized" });
return;
}
const { software, action, ip, patientId, ratioTest } = req.body ?? {};
if (software !== "open-dental" || action !== "existing-patient") {
res.status(400).json({ error: "This software/action combination isn't automated yet." });
return;
}
if (!ip) {
res.status(400).json({ error: "ip is required" });
return;
}
const patient = await storage.getPatient(Number(patientId));
if (!patient?.lastName || !patient?.firstName) {
res.status(400).json({ error: "Patient not found or missing a first/last name" });
return;
}
const runId = crypto.randomUUID();
res.json({ runId });
const runFn = ratioTest ? runExistingPatientOpenDentalRatioTest : runExistingPatientOpenDental;
runFn(userId, runId, ip, patient.lastName, patient.firstName, (index, total, label, status, error) => {
io?.emit(`type-agent:run:${runId}`, { index, total, label, status, error });
}).catch((err) => {
console.error("[type-agent/run] failed:", err);
});
});
export default router;
+73
View File
@@ -0,0 +1,73 @@
import axios from "axios";
import FormData from "form-data";
export interface OcrWord {
text: string;
left: number;
top: number;
w: number;
h: number;
cx: number;
cy: number;
}
// Runs OCR (Google Vision via PaymentOCRService, already running for payment-document
// extraction) on a screenshot ONCE and returns every detected word with its exact pixel bounding
// box. Callers that need to look up several different pieces of text in the same screenshot
// (e.g. a header, a last name, and a first name all in one patient-list crop) should call this
// once and match against the result multiple times with matchWordsForText, rather than paying
// for a separate billed Vision API call per text target.
export async function getOcrWords(imageBase64: string): Promise<OcrWord[]> {
const form = new FormData();
form.append("file", Buffer.from(imageBase64, "base64"), {
filename: "screenshot.png",
contentType: "image/png",
});
try {
const resp = await axios.post<{ words: OcrWord[] }>("http://localhost:5003/extract/words", form, {
headers: form.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
timeout: 30000,
});
return resp.data?.words ?? [];
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.detail || err?.message || "Unknown error";
throw new Error(`OCR request failed${status ? ` (${status})` : ""}: ${detail}`);
}
}
// Finds every occurrence of exact text within an already-OCR'd word list. Google Vision's word
// bounding boxes give an exact pixel center, so unlike locateAllOnScreenshot's AI-estimated
// top/bottom edges, there's no systematic bias to correct for.
//
// `text` may be multiple words (e.g. "Single click") — Google Vision reports one bounding box
// per individual word, not per phrase, so a multi-word target is matched as a run of consecutive
// words in the OCR result (which preserves reading order) and their boxes are merged into one.
export function matchWordsForText(words: OcrWord[], text: string): { x: number; y: number }[] {
const targetTokens = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (targetTokens.length === 0) return [];
const matches: { x: number; y: number }[] = [];
for (let i = 0; i <= words.length - targetTokens.length; i++) {
const span = words.slice(i, i + targetTokens.length);
const matchesHere = span.every((w, j) => w.text.trim().toLowerCase() === targetTokens[j]);
if (!matchesHere) continue;
const left = Math.min(...span.map((w) => w.left));
const top = Math.min(...span.map((w) => w.top));
const right = Math.max(...span.map((w) => w.left + w.w));
const bottom = Math.max(...span.map((w) => w.top + w.h));
matches.push({ x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) });
}
return matches;
}
// Convenience wrapper for callers that only need a single text lookup per screenshot (one OCR
// call, one match pass) — e.g. the Save button step, which only ever searches for one thing.
export async function locateAllViaOcr(imageBase64: string, text: string): Promise<{ x: number; y: number }[]> {
const words = await getOcrWords(imageBase64);
return matchWordsForText(words, text);
}
+8 -1
View File
@@ -46,9 +46,16 @@ export async function startWebDavServer(): Promise<void> {
const config = readRcloneConfig(); const config = readRcloneConfig();
// Serve only the backups folder — never the app root, which contains
// .env (DB/Twilio/AI credentials) and the full source tree.
const backupsDir = path.join(APP_ROOT, "backups");
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
const args = [ const args = [
"serve", "webdav", "serve", "webdav",
APP_ROOT, backupsDir,
"--addr", `:${config.serverPort}`, "--addr", `:${config.serverPort}`,
"--user", RCLONE_USER, "--user", RCLONE_USER,
"--pass", RCLONE_PASS, "--pass", RCLONE_PASS,
@@ -0,0 +1,458 @@
import sharp from "sharp";
import { captureScreenshot, click, doubleClick, doubleClickCurrent, moveMouse, typeText } from "./windowsAgentBridge";
import {
confirmTextTyped,
cropAroundPoint,
cropToRegion,
detectRowHeightRatio,
diffBoundingBox,
locateOnScreenshot,
WindowBounds,
} from "./visionLocate";
import { getOcrWords, locateAllViaOcr, matchWordsForText } from "./ocrLocate";
import { backupTypeAgentScreenshot, logTypeAgentStep } from "../utils/screenshotBackup";
export type StepStatus = "running" | "done" | "error";
export type ProgressCallback = (
index: number,
total: number,
label: string,
status: StepStatus,
error?: string
) => void;
interface RunContext {
ip: string;
userId: number;
runId: string;
patientLastName: string;
patientFirstName: string;
// Set once a dialog's bounds are detected (via diffBoundingBox) — every locate/click after
// that point is restricted to this region until it's replaced by the next dialog's bounds.
windowBounds: WindowBounds | null;
// The screenshot taken right before the action that's expected to open the *next* dialog —
// stashed here so the following step can diff against it without re-capturing.
beforeNextWindow: string | null;
}
interface RunStep {
label: string;
execute: (ctx: RunContext) => Promise<void>;
// Time to let the target app's UI settle/render before the next step's screenshot.
delayAfterMs: number;
}
// Captures the current screen and, if a window has been detected, crops it down to just that
// window — so every locate call after that point only ever sees the relevant dialog, never the
// rest of the screen (the main window's title bar, side panels, etc.).
async function captureWindowScreenshot(ctx: RunContext): Promise<{ full: string; region: string }> {
const { image } = await captureScreenshot(ctx.ip);
const region = ctx.windowBounds ? await cropToRegion(image, ctx.windowBounds) : image;
return { full: image, region };
}
// Translates a point from window-cropped-image coordinates back to real screen coordinates.
function toScreenPoint(ctx: RunContext, point: { x: number; y: number }): { x: number; y: number } {
if (!ctx.windowBounds) return point;
return { x: point.x + ctx.windowBounds.left, y: point.y + ctx.windowBounds.top };
}
// Same, for a whole list — used only when logging matches, so run.log always reports real
// screen coordinates (matching the full-screen debug screenshots) even though the underlying
// comparisons that pick a target run in cropped-image space.
function toScreenPoints(ctx: RunContext, points: { x: number; y: number }[]): { x: number; y: number }[] {
return points.map((p) => toScreenPoint(ctx, p));
}
// Keeps a point inside the currently-tracked window — every movement/click is restricted to
// this region once one is set, so a bad locate can't send the cursor wandering off into the
// rest of the app.
function clampToWindow(ctx: RunContext, x: number, y: number): { x: number; y: number } {
if (!ctx.windowBounds) return { x, y };
const { left, top, width, height } = ctx.windowBounds;
return {
x: Math.min(Math.max(x, left), left + width - 1),
y: Math.min(Math.max(y, top), top + height - 1),
};
}
// Moves to (x, y) — clamped to the tracked window — saves a small marked debug crop of what's
// there (for after-the-fact review, not fed back to any AI call), then clicks.
async function debugClickAt(
ctx: RunContext,
rawX: number,
rawY: number,
label: string,
opts?: { doubleClick?: boolean }
) {
const { x, y } = clampToWindow(ctx, rawX, rawY);
await moveMouse(x, y, ctx.ip);
const { image } = await captureScreenshot(ctx.ip);
const crop = await cropAroundPoint(image, x, y);
backupTypeAgentScreenshot(ctx.runId, `${label}_debug`, crop);
const action = opts?.doubleClick ? "double_click" : "click";
console.log(`[type-agent] ${opts?.doubleClick ? "double-click" : "click"} at (${x}, ${y}) — ${label}`);
logTypeAgentStep(ctx.runId, { event: action, label, pixelX: x, pixelY: y });
if (opts?.doubleClick) {
await doubleClick(x, y, ctx.ip);
} else {
await click(x, y, ctx.ip);
}
}
// Detects a newly-opened dialog by diffing the screenshot stashed in ctx.beforeNextWindow
// against the current screen, and adopts its bounds as the window every subsequent locate/click
// is restricted to. The four corners (A/B/C/D) are just this rectangle's corners, logged for
// visibility.
async function detectAndTrackNewWindow(ctx: RunContext, label: string): Promise<void> {
if (!ctx.beforeNextWindow) {
throw new Error(`No "before" screenshot captured for detecting the ${label} window`);
}
const { image: after } = await captureScreenshot(ctx.ip);
const bounds = await diffBoundingBox(ctx.beforeNextWindow, after);
if (!bounds) {
throw new Error(`Could not detect the ${label} window opening (no significant screen change)`);
}
ctx.windowBounds = bounds;
ctx.beforeNextWindow = null;
logTypeAgentStep(ctx.runId, {
event: "window_detected",
label,
A: { x: bounds.left, y: bounds.top },
B: { x: bounds.left + bounds.width, y: bounds.top },
C: { x: bounds.left + bounds.width, y: bounds.top + bounds.height },
D: { x: bounds.left, y: bounds.top + bounds.height },
});
}
// Open Dental, "make an appointment for an existing patient":
// 1. Screenshot, then double-click the pre-positioned schedule slot.
// 2. Diff against that screenshot to find the newly-opened Select Patient dialog's bounds —
// every following locate/click in this dialog is restricted to that region, so nothing
// outside it (the main window's title bar, side panels) can ever be matched by mistake.
// 3. Click the Last Name field (found by vision, now unambiguous since the window is small
// and cropped) and type the first three letters — no first name is typed.
// 4. Locate the target patient's first and last name text directly in the now-filtered results
// grid (the 3-letter filter can return more than one patient) and pair up whichever
// occurrences share a row to find the target row's real position, then double-click it
// (opens Edit Appointment).
// 5. Diff again to find the Edit Appointment window's bounds, then click Exam and Save
// within it.
const OPEN_DENTAL_EXISTING_PATIENT: RunStep[] = [
{
label: "Screenshot, then double-click the schedule at the cursor position",
execute: async (ctx) => {
const { image } = await captureScreenshot(ctx.ip);
ctx.beforeNextWindow = image;
await doubleClickCurrent(ctx.ip);
},
delayAfterMs: 1000, // Select Patient window opening
},
{
label: "Detect the Select Patient window",
execute: async (ctx) => {
await detectAndTrackNewWindow(ctx, "Select Patient");
},
delayAfterMs: 100,
},
{
label: "Find and click the Last Name field, type the first three letters",
execute: async (ctx) => {
const lastThree = ctx.patientLastName.slice(0, 3);
const { region } = await captureWindowScreenshot(ctx);
const loc = await locateOnScreenshot(
ctx.userId,
region,
'the text input field immediately to the right of the "Last Name" label'
);
if (!loc) throw new Error('Could not find the "Last Name" field in the Select Patient window');
const screen = toScreenPoint(ctx, loc);
logTypeAgentStep(ctx.runId, { event: "locate", label: "Last Name field", pixelX: screen.x, pixelY: screen.y });
await debugClickAt(ctx, screen.x, screen.y, "last_name_field");
await typeText(lastThree, ctx.ip);
},
delayAfterMs: 600, // patient list filtering
},
{
label: "Verify the last name fragment was typed",
execute: async (ctx) => {
const { full } = await captureWindowScreenshot(ctx);
backupTypeAgentScreenshot(ctx.runId, "typed_check", full);
const lastThree = ctx.patientLastName.slice(0, 3);
const typed = await confirmTextTyped(ctx.userId, full, lastThree);
logTypeAgentStep(ctx.runId, { event: "typed_check", typed });
if (!typed) {
throw new Error(`"${lastThree}" doesn't appear to have been typed anywhere on screen`);
}
},
delayAfterMs: 200,
},
{
label: "Find and double-click the matching patient row",
execute: async (ctx) => {
const { region } = await captureWindowScreenshot(ctx);
backupTypeAgentScreenshot(ctx.runId, "patient_row_locate", region);
// One OCR call for the whole region, matched against locally for each of the three text
// targets below — Vision bills per call, and all three targets live in this same
// screenshot, so there's no reason to pay for three separate OCR passes over it.
const ocrWords = await getOcrWords(region);
const headerMatches = matchWordsForText(ocrWords, "PatNum");
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "PatNum header", matches: toScreenPoints(ctx, headerMatches) });
if (headerMatches.length === 0) throw new Error('Could not find the "PatNum" column header');
const header = headerMatches.reduce((a, b) => (a.y < b.y ? a : b));
// Locate the target patient's first and last name directly via OCR (real pixel bounding
// boxes from Google Vision), rather than computing the row's y arithmetically (headerY +
// rowHeight * rowIndex) — a previous version did that and the click drifted further off-row
// the more rows down the target was. An earlier AI-vision version of this step (asking
// Claude to estimate each word's top/bottom edge) also had a small but consistent upward
// bias since it was an LLM's visual estimate, not a measurement. OCR bounding-box centers
// are exact, so the same-row pairing below needs no bias correction.
const lastNameMatches = matchWordsForText(ocrWords, ctx.patientLastName);
const firstNameMatches = matchWordsForText(ocrWords, ctx.patientFirstName);
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "last name", matches: toScreenPoints(ctx, lastNameMatches) });
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "first name", matches: toScreenPoints(ctx, firstNameMatches) });
if (lastNameMatches.length === 0) throw new Error(`Could not find "${ctx.patientLastName}" in the patient list`);
if (firstNameMatches.length === 0) throw new Error(`Could not find "${ctx.patientFirstName}" in the patient list`);
// Row height is only used here as a "same row" tolerance for the pairing below, not
// multiplied by anything — so any imprecision in it no longer compounds with row distance.
const rowHeightRatio = await detectRowHeightRatio(ctx.userId, region);
const rowHeightPx = Math.round(rowHeightRatio * (ctx.windowBounds?.height ?? 0));
if (rowHeightPx <= 0) throw new Error(`AI reported an invalid row height ratio: ${rowHeightRatio}`);
let best: { last: { x: number; y: number }; first: { x: number; y: number }; dist: number } | null = null;
for (const last of lastNameMatches) {
for (const first of firstNameMatches) {
const dist = Math.abs(last.y - first.y);
if (dist <= rowHeightPx / 2 && (!best || dist < best.dist)) {
best = { last, first, dist };
}
}
}
if (!best) {
throw new Error(
`Could not find a row where "${ctx.patientFirstName}" and "${ctx.patientLastName}" are on the same line`
);
}
const screen = toScreenPoint(ctx, { x: header.x, y: best.last.y });
logTypeAgentStep(ctx.runId, {
event: "locate",
label: "matching patient row",
pixelX: screen.x,
finalY: screen.y,
lastNameMatch: toScreenPoint(ctx, best.last),
firstNameMatch: toScreenPoint(ctx, best.first),
rowHeightPx,
});
ctx.beforeNextWindow = (await captureScreenshot(ctx.ip)).image;
await debugClickAt(ctx, screen.x, screen.y, "patient_row", { doubleClick: true });
},
delayAfterMs: 1200, // Edit Appointment window opening
},
{
label: "Detect the Edit Appointment window",
execute: async (ctx) => {
await detectAndTrackNewWindow(ctx, "Edit Appointment");
},
delayAfterMs: 100,
},
{
label: "Find and click the Exam procedure in the upper middle column",
execute: async (ctx) => {
const { region } = await captureWindowScreenshot(ctx);
backupTypeAgentScreenshot(ctx.runId, "exam_locate", region);
// The Edit Appointment window is laid out in three big panels: Patient Info (left), the
// clickable single-click procedures list (middle), and the already-added procedures grid
// plus comm log (right). "Exam" appears both as a clickable item in the middle list AND
// as an already-added row in the right grid — telling them apart by y-position alone isn't
// reliable since both can land at a similar height. Column-divider detection (asking AI to
// find the panel-separator lines) used to disambiguate this, but that's a visual-layout
// judgment call prone to over/under-counting dividers (seen in practice: 3 found instead of
// 2, hard-failing the whole step). "Single click" is a hint label that only ever appears
// once, directly above the correct clickable list — so the "Exam" occurrence nearest it in
// x is structurally the right one, without needing to know where the panel boundaries are.
// One OCR call for the whole region, matched locally against both text targets — same
// reasoning as the patient-row step above.
const ocrWords = await getOcrWords(region);
const examMatches = matchWordsForText(ocrWords, "Exam");
const singleClickMatches = matchWordsForText(ocrWords, "Single click");
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Exam", matches: toScreenPoints(ctx, examMatches) });
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Single click", matches: toScreenPoints(ctx, singleClickMatches) });
if (examMatches.length === 0) throw new Error('Could not find "Exam" in the Edit Appointment window');
if (singleClickMatches.length === 0) throw new Error('Could not find "Single click" in the Edit Appointment window');
// Topmost "Single click" occurrence, in case OCR ever reports more than one.
const singleClick = singleClickMatches.reduce((a, b) => (a.y < b.y ? a : b));
// Nearest "Exam" match to that x — the one in the unrelated right-side grid sits in a
// different column, far enough in x not to compete with the real target.
const examLoc = examMatches.reduce((a, b) =>
Math.abs(a.x - singleClick.x) <= Math.abs(b.x - singleClick.x) ? a : b
);
// OCR's bounding-box center is exact, so the click lands directly on it — no row-height
// grid estimate needed to nudge toward a row's middle.
const screen = toScreenPoint(ctx, examLoc);
logTypeAgentStep(ctx.runId, {
event: "exam_match",
pixelX: screen.x,
finalY: screen.y,
singleClickMatch: toScreenPoint(ctx, singleClick),
});
await debugClickAt(ctx, screen.x, screen.y, "exam");
},
delayAfterMs: 500,
},
{
label: "Find and click Save",
execute: async (ctx) => {
const { region } = await captureWindowScreenshot(ctx);
backupTypeAgentScreenshot(ctx.runId, "save_locate", region);
const saveMatches = await locateAllViaOcr(region, "Save");
logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Save", matches: toScreenPoints(ctx, saveMatches) });
if (saveMatches.length === 0) throw new Error("Could not find the Save button in the Edit Appointment window");
// "Save" only ever appears once as a button label in this window, but if OCR ever reports
// more than one, the real one is in the LOWER part of the RIGHTMOST column (below the other
// action buttons there, e.g. "Delete", "To Task List", "Audit Trail") — so the bottom-right-
// most occurrence is the correct disambiguation, same reasoning the old AI-vision prompt used.
const saveLoc = saveMatches.reduce((a, b) => (a.x + a.y >= b.x + b.y ? a : b));
const screen = toScreenPoint(ctx, saveLoc);
logTypeAgentStep(ctx.runId, { event: "locate", label: "Save", pixelX: screen.x, pixelY: screen.y });
await debugClickAt(ctx, screen.x, screen.y, "save");
},
delayAfterMs: 300,
},
];
async function runSteps(ctx: RunContext, steps: RunStep[], onProgress: ProgressCallback): Promise<void> {
logTypeAgentStep(ctx.runId, {
event: "run_start",
patientLastName: ctx.patientLastName,
patientFirstName: ctx.patientFirstName,
ip: ctx.ip,
totalSteps: steps.length,
});
for (let i = 0; i < steps.length; i++) {
const step = steps[i]!;
onProgress(i, steps.length, step.label, "running");
logTypeAgentStep(ctx.runId, { event: "step_start", index: i, label: step.label });
try {
await step.execute(ctx);
onProgress(i, steps.length, step.label, "done");
logTypeAgentStep(ctx.runId, { event: "step_done", index: i, label: step.label });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
onProgress(i, steps.length, step.label, "error", message);
logTypeAgentStep(ctx.runId, { event: "step_error", index: i, label: step.label, error: message });
throw err;
}
await new Promise((resolve) => setTimeout(resolve, step.delayAfterMs));
}
logTypeAgentStep(ctx.runId, { event: "run_done" });
}
export async function runExistingPatientOpenDental(
userId: number,
runId: string,
ip: string,
patientLastName: string,
patientFirstName: string,
onProgress: ProgressCallback
): Promise<void> {
const ctx: RunContext = {
ip,
userId,
runId,
patientLastName,
patientFirstName,
windowBounds: null,
beforeNextWindow: null,
};
await runSteps(ctx, OPEN_DENTAL_EXISTING_PATIENT, onProgress);
}
// TEMPORARY TEST PATH — types the full last name directly (relying on the Select Patient
// window's default focus, no field click) and then moves to a hardcoded ratio (0.31, 0.21) —
// the position manually read off a real "Allowed, Allen" row screenshot — instead of any
// vision lookup. Exists purely to test the ratio→pixel conversion and move/double-click
// mechanics in isolation from AI locate mistakes. Kept separate from the real flow above,
// which now types into both name fields individually and uses window-relative locates instead.
const TEST_ROW_X_RATIO = 0.31;
const TEST_ROW_Y_RATIO = 0.21;
const OPEN_DENTAL_EXISTING_PATIENT_RATIO_TEST: RunStep[] = [
{
label: "Double-click the schedule at the cursor position",
execute: async (ctx) => {
await doubleClickCurrent(ctx.ip);
},
delayAfterMs: 1000, // Select Patient window opening
},
{
label: "Type the patient's last name (no field click first — relies on default focus)",
execute: async (ctx) => {
await typeText(ctx.patientLastName, ctx.ip);
},
delayAfterMs: 600, // patient list filtering
},
{
label: "TEST: verify the last name was typed",
execute: async (ctx) => {
const { image } = await captureScreenshot(ctx.ip);
backupTypeAgentScreenshot(ctx.runId, `ratio_test_typed_check_${ctx.patientLastName}`, image);
const typed = await confirmTextTyped(ctx.userId, image, ctx.patientLastName);
if (!typed) {
throw new Error(`"${ctx.patientLastName}" doesn't appear to have been typed anywhere on screen`);
}
},
delayAfterMs: 200,
},
{
label: `TEST: move to fixed ratio (${TEST_ROW_X_RATIO}, ${TEST_ROW_Y_RATIO}) and double-click`,
execute: async (ctx) => {
const { image } = await captureScreenshot(ctx.ip);
const { width = 0, height = 0 } = await sharp(Buffer.from(image, "base64")).metadata();
const x = Math.round(TEST_ROW_X_RATIO * width);
const y = Math.round(TEST_ROW_Y_RATIO * height);
console.log(`[type-agent] TEST double-click at ratio (${TEST_ROW_X_RATIO}, ${TEST_ROW_Y_RATIO}) -> pixel (${x}, ${y})`);
await moveMouse(x, y, ctx.ip);
await doubleClick(x, y, ctx.ip);
},
delayAfterMs: 1200,
},
];
export async function runExistingPatientOpenDentalRatioTest(
userId: number,
runId: string,
ip: string,
patientLastName: string,
patientFirstName: string,
onProgress: ProgressCallback
): Promise<void> {
const ctx: RunContext = {
ip,
userId,
runId,
patientLastName,
patientFirstName,
windowBounds: null,
beforeNextWindow: null,
};
await runSteps(ctx, OPEN_DENTAL_EXISTING_PATIENT_RATIO_TEST, onProgress);
}
+471
View File
@@ -0,0 +1,471 @@
import sharp from "sharp";
import { storage } from "../storage";
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
// Side length (px) of the debug crop saved around a click point — big enough to show
// surrounding context (e.g. neighboring grid rows) when reviewing what a run actually clicked.
const DEBUG_CROP_SIZE = 440;
interface ParsedLocation {
found: boolean;
xRatio?: number;
yRatio?: number;
}
// Parses the model's response into {found, xRatio, yRatio}, tolerating near-miss JSON rather
// than only accepting strictly valid JSON. Seen in practice with the old pixel-based format:
// {"found": true, "x": 1023, 187} — a dropped key with the value still present positionally.
// Since this response should only ever contain two numbers (the coordinates), falling back to
// "first two numbers after found" is safe and recovers without burning another AI call.
function parseLocationResponse(raw: string): ParsedLocation | null {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return null;
const block = match[0];
try {
return JSON.parse(block) as ParsedLocation;
} catch {
// fall through to lenient repair below
}
const foundMatch = block.match(/"found"\s*:\s*(true|false)/i);
if (!foundMatch) return null;
const found = foundMatch[1]!.toLowerCase() === "true";
if (!found) return { found: false };
const afterFound = block.slice(foundMatch.index! + foundMatch[0].length);
const numbers = afterFound.match(/-?\d+(\.\d+)?/g);
if (!numbers || numbers.length < 2) return null;
return { found: true, xRatio: Number(numbers[0]), yRatio: Number(numbers[1]) };
}
// Asks the configured vision AI to find a described UI element in a screenshot and return
// its position — the core primitive the Type Agent step runner clicks/types through.
// Reuses the same Claude-vision pattern as /api/ai/detect-eligibility-info.
//
// The AI reports position as a 0-1 ratio of image width/height rather than absolute pixels.
// Vision models are more reliable at "this is 4/10 of the way across, 3/10 down" than at
// naming an exact pixel — asking for pixels directly produced coordinates that were close but
// occasionally off by enough to land on a neighboring row or the title bar above it. A ratio
// is resolution-independent by construction, so it's converted to real screen pixels here
// using the actual screenshot dimensions rather than trusting the model to know the image size.
export async function locateOnScreenshot(
userId: number,
imageBase64: string,
goal: string
): Promise<{ x: number; y: number; xRatio: number; yRatio: number } | null> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0, height = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Find this UI element: "${goal}". ` +
"Respond with strict JSON only, no prose, no markdown fences: " +
'{"found": true, "xRatio": <0 to 1>, "yRatio": <0 to 1>} if you can locate it, or ' +
'{"found": false} if it isn\'t visible in this screenshot. xRatio is how far across the ' +
"image the element's center is, as a fraction of the TOTAL image width (0 = left edge, " +
"0.5 = horizontal center, 1 = right edge). yRatio is how far down the element's center is, " +
"as a fraction of the TOTAL image height (0 = top edge, 1 = bottom edge). Use two decimal " +
"places of precision — do not report pixel coordinates.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
// A response that's fully unparseable even after the lenient repair above is worth one
// retry (rare model hiccup); anything the repair can salvage is used immediately, no
// retry needed.
const MAX_ATTEMPTS = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const parsed = parseLocationResponse(raw);
if (!parsed) {
lastError = new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
continue;
}
if (!parsed.found || typeof parsed.xRatio !== "number" || typeof parsed.yRatio !== "number") {
return null;
}
return {
x: Math.round(parsed.xRatio * width),
y: Math.round(parsed.yRatio * height),
xRatio: parsed.xRatio,
yRatio: parsed.yRatio,
};
}
throw lastError ?? new Error("AI returned an unparseable response");
}
// Crops a small square around (x, y) and draws a red crosshair marker at that exact point —
// a debug artifact saved before each click so a person can see afterward exactly where the
// run was about to click. A real screen capture won't reliably include the OS mouse cursor
// (BitBlt-based grabs typically omit it), so this synthetic marker stands in for it.
export async function cropAroundPoint(imageBase64: string, x: number, y: number): Promise<string> {
const source = sharp(Buffer.from(imageBase64, "base64"));
const { width = 0, height = 0 } = await source.metadata();
const cropWidth = Math.min(DEBUG_CROP_SIZE, width);
const cropHeight = Math.min(DEBUG_CROP_SIZE, height);
const left = Math.round(Math.max(0, Math.min(x - cropWidth / 2, width - cropWidth)));
const top = Math.round(Math.max(0, Math.min(y - cropHeight / 2, height - cropHeight)));
const markerX = x - left;
const markerY = y - top;
const crosshair = Buffer.from(
`<svg width="${cropWidth}" height="${cropHeight}">` +
`<line x1="${markerX - 12}" y1="${markerY}" x2="${markerX + 12}" y2="${markerY}" stroke="red" stroke-width="2"/>` +
`<line x1="${markerX}" y1="${markerY - 12}" x2="${markerX}" y2="${markerY + 12}" stroke="red" stroke-width="2"/>` +
`<circle cx="${markerX}" cy="${markerY}" r="7" fill="none" stroke="red" stroke-width="2"/>` +
`</svg>`
);
const cropped = await source
.extract({ left, top, width: cropWidth, height: cropHeight })
.composite([{ input: crosshair }])
.png()
.toBuffer();
return cropped.toString("base64");
}
interface RawMatch {
xRatio: number;
yTopRatio: number;
yBottomRatio: number;
}
// Parses {"matches": [{"xRatio":.., "yTopRatio":.., "yBottomRatio":..}, ...]}, tolerating
// near-miss JSON the same way parseLocationResponse does — falls back to regex-extracting every
// triple in the response if strict parsing fails, rather than treating a malformed-but-salvageable
// response as zero matches.
function parseMatchesResponse(raw: string): RawMatch[] | null {
const block = raw.match(/\{[\s\S]*\}/)?.[0];
if (!block) return null;
try {
const parsed = JSON.parse(block);
if (Array.isArray(parsed.matches)) return parsed.matches;
} catch {
// fall through to lenient repair below
}
const triples: RawMatch[] = [];
const tripleRegex =
/"xRatio"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"yTopRatio"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"yBottomRatio"\s*:\s*(-?\d+(?:\.\d+)?)/g;
let match: RegExpExecArray | null;
while ((match = tripleRegex.exec(block))) {
triples.push({ xRatio: Number(match[1]), yTopRatio: Number(match[2]), yBottomRatio: Number(match[3]) });
}
return triples.length > 0 ? triples : null;
}
// Like locateOnScreenshot, but finds every occurrence of an exact piece of text rather than
// one best guess for a described element. Used to disambiguate by position instead of by
// asking the AI to judge which occurrence is "the right one" — e.g. a last name shows up once
// in the search box and once per matching patient row, so the caller pairs it against another
// text's occurrences (a first name) that share the same row instead of relying on a single
// fuzzy vision judgment call.
//
// Reports each match's TOP and BOTTOM pixel edge rather than asking the model to self-estimate a
// "center" — a center is an abstract judgment call, whereas the top/bottom of a glyph is a
// concrete, visible thing to point at. The vertical midpoint used for x/y below is then computed
// here in code from those two real edges, not trusted as a direct AI estimate.
export async function locateAllOnScreenshot(
userId: number,
imageBase64: string,
text: string
): Promise<{ x: number; y: number }[]> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0, height = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Find EVERY occurrence of the text ` +
`"${text}" visible anywhere in this screenshot (case-insensitive) — there may be zero, one, ` +
"or several. Respond with strict JSON only, no prose, no markdown fences: " +
'{"matches": [{"xRatio": <0 to 1>, "yTopRatio": <0 to 1>, "yBottomRatio": <0 to 1>}, ...]} — ' +
"one entry per occurrence. xRatio is how far across the image the occurrence's horizontal " +
"center is, as a fraction of the TOTAL image width. yTopRatio is where the TOP edge of that " +
"occurrence's text (the top of its tallest letters) is, and yBottomRatio is where its BOTTOM " +
"edge (the bottom of its lowest letters, including descenders) is — both as a fraction of " +
"the TOTAL image height. Use an empty array if there are no occurrences.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const MAX_ATTEMPTS = 2;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const matches = parseMatchesResponse(raw);
if (!matches) {
lastError = new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
continue;
}
return matches.map((m) => ({
x: Math.round(m.xRatio * width),
y: Math.round(((m.yTopRatio + m.yBottomRatio) / 2) * height),
}));
}
throw lastError ?? new Error("AI returned an unparseable response");
}
// Asks the AI to measure the height of one row in a results-grid list, as a ratio of the
// image's total height — used to build a grid of valid row-center y-positions so a click can be
// snapped toward the middle of whichever row it's closest to, rather than trusting a single
// direct locate call's y-coordinate on its own (which tends to land near a row's top edge).
export async function detectRowHeightRatio(userId: number, imageBase64: string): Promise<number> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content = [
{
type: "text",
text:
"This is a screenshot containing a results-grid list of rows (e.g. a patient list) with a " +
"header row followed by data rows, all data rows the same height. IGNORE the header row — " +
"it is taller than a data row and would skew the measurement. Look at ALL the visible data " +
"rows (not just one) and measure their AVERAGE height. Respond with strict JSON only, no " +
'prose, no markdown fences: {"rowHeightRatio": <0 to 1>} — the average data row height as ' +
"a fraction of the TOTAL image height. Use three decimal places of precision.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const match = raw.match(/"rowHeightRatio"\s*:\s*(-?\d+(?:\.\d+)?)/);
if (!match) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
return Number(match[1]);
}
// Asks the AI to find the x-positions of the MAJOR vertical dividers that split a window into
// its big panels/columns (e.g. a "Patient Info" panel, a middle fields+list panel, a right-side
// grid panel) — as opposed to minor lines inside a single table's own columns. Used to tell
// apart same-text matches that live in different panels (e.g. "Exam" appearing both in a
// clickable procedures list and, separately, in an already-added-procedures grid) by which
// column they actually fall in, rather than guessing from y-position alone.
export async function detectColumnBoundaries(userId: number, imageBase64: string): Promise<number[]> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const { width = 0 } = await sharp(Buffer.from(imageBase64, "base64")).metadata();
const content = [
{
type: "text",
text:
"This screenshot shows a Windows dialog laid out in a small number of large vertical " +
"sections (columns) — e.g. a distinct panel on the left, a middle section with fields " +
'and/or a list, and another panel or grid on the right. Find the x-position of each MAJOR ' +
"vertical divider between these big sections (a visible line, border, or clear gap — NOT " +
"the minor column lines inside a single table). Respond with strict JSON only, no prose, " +
'no markdown fences: {"dividerXRatios": [<0 to 1>, ...]} — one entry per major divider, ' +
"sorted left to right, as a fraction of the TOTAL image width. Use two decimal places of " +
"precision.",
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${imageBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const block = raw.match(/\{[\s\S]*\}/)?.[0];
if (!block) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
let ratios: number[];
try {
const parsed = JSON.parse(block);
if (!Array.isArray(parsed.dividerXRatios)) throw new Error("not an array");
ratios = parsed.dividerXRatios;
} catch {
const arrayMatch = block.match(/"dividerXRatios"\s*:\s*\[([^\]]*)\]/);
if (!arrayMatch) throw new Error(`AI returned an unparseable response: ${raw.slice(0, 200)}`);
ratios = arrayMatch[1]!
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => !Number.isNaN(n));
}
return ratios.map((r) => Math.round(r * width)).sort((a, b) => a - b);
}
export interface WindowBounds {
left: number;
top: number;
width: number;
height: number;
}
// Compares two full screenshots and returns the bounding box of whatever changed
// significantly between them — used to find a newly-opened dialog's exact position/size with
// pixel math instead of an AI call, so later steps can crop every screenshot down to just that
// window and never even show the AI anything outside it (the earlier bug where a name prefix
// happened to also match unrelated text elsewhere on screen, e.g. "All Received" in a side
// panel, is structurally impossible once matching only ever sees the cropped window).
//
// Rather than a raw min/max over any differing pixel, this counts changed pixels per row and
// per column and only counts a row/column as part of the new window once a meaningful fraction
// of it changed — a real window opening changes a large contiguous block, whereas incidental
// noise elsewhere (a blinking clock, a flickering tray icon) only touches a handful of pixels
// and would otherwise blow the bounding box out to include it.
export async function diffBoundingBox(
beforeBase64: string,
afterBase64: string
): Promise<WindowBounds | null> {
const [before, after] = await Promise.all([
sharp(Buffer.from(beforeBase64, "base64")).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
sharp(Buffer.from(afterBase64, "base64")).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
]);
const { data: b, info } = before;
const { data: a, info: infoAfter } = after;
if (info.width !== infoAfter.width || info.height !== infoAfter.height) return null;
const { width, height, channels } = info;
const PIXEL_DIFF_THRESHOLD = 40; // sum of |dR| + |dG| + |dB| to count a pixel as "changed"
const rowCounts = new Uint32Array(height);
const colCounts = new Uint32Array(width);
for (let y = 0; y < height; y++) {
const rowBase = y * width * channels;
for (let x = 0; x < width; x++) {
const idx = rowBase + x * channels;
const diff = Math.abs(a[idx]! - b[idx]!) + Math.abs(a[idx + 1]! - b[idx + 1]!) + Math.abs(a[idx + 2]! - b[idx + 2]!);
if (diff > PIXEL_DIFF_THRESHOLD) {
rowCounts[y] = (rowCounts[y] ?? 0) + 1;
colCounts[x] = (colCounts[x] ?? 0) + 1;
}
}
}
const rowThreshold = width * 0.05;
const colThreshold = height * 0.05;
let top = -1;
let bottom = -1;
for (let y = 0; y < height; y++) {
if (rowCounts[y]! > rowThreshold) {
if (top === -1) top = y;
bottom = y;
}
}
let left = -1;
let right = -1;
for (let x = 0; x < width; x++) {
if (colCounts[x]! > colThreshold) {
if (left === -1) left = x;
right = x;
}
}
if (top === -1 || left === -1) return null;
return { left, top, width: right - left + 1, height: bottom - top + 1 };
}
// Plain crop, no marker — used to restrict a screenshot to a previously-detected window's
// bounds before handing it to any locate call.
export async function cropToRegion(imageBase64: string, region: WindowBounds): Promise<string> {
const cropped = await sharp(Buffer.from(imageBase64, "base64"))
.extract({ left: region.left, top: region.top, width: region.width, height: region.height })
.png()
.toBuffer();
return cropped.toString("base64");
}
// Sanity check for steps that type without first vision-clicking into a field (relying on
// whatever already has focus) — confirms the text actually landed somewhere visible on
// screen before moving on, catching the case where focus wasn't where it was assumed to be.
export async function confirmTextTyped(
userId: number,
fullScreenshotBase64: string,
expectedText: string
): Promise<boolean> {
const aiSettings = await storage.getAiSettings(userId);
const activeAi = resolveAiProvider(aiSettings ?? {});
if (!activeAi) {
throw new Error("AI is not configured. Add an API key in AI Settings.");
}
if (activeAi.provider !== "claude") {
throw new Error("Vision-guided steps require Claude to be the active AI provider.");
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const content = [
{
type: "text",
text:
`This is a screenshot of a Windows desktop application. Is the text "${expectedText}" ` +
"visible typed into some text input field on screen? Respond with strict JSON only, no " +
'prose, no markdown fences: {"confirmed": true} if that text is visible in a field, or ' +
'{"confirmed": false} if it is not visible anywhere.',
},
{
type: "image_url",
image_url: { url: `data:image/png;base64,${fullScreenshotBase64}` },
},
];
const response = await llm.invoke([{ role: "user", content }] as any);
const raw = String(response.content).trim();
const match = raw.match(/"confirmed"\s*:\s*(true|false)/i);
return match?.[1]?.toLowerCase() === "true";
}
@@ -0,0 +1,64 @@
import { io } from "../socket";
const COMMAND_TIMEOUT_MS = 10000;
// Picks a connected Windows Agent socket by IP address — falls back to the first connected
// agent if none is given (fine while offices only ever have one PC connected).
function getAgentSocket(ip?: string) {
if (!io) throw new Error("Socket.io not initialized");
const namespaceSockets = Array.from(io.of("/agent").sockets.values());
const target = ip
? namespaceSockets.find((s) => {
const socketIp = (s.handshake.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|| s.handshake.address;
return socketIp === ip;
})
: namespaceSockets[0];
if (!target) throw new Error("No Windows Agent connected");
return target;
}
async function sendCommand<T>(event: string, payload: unknown, ip?: string): Promise<T> {
const socket = getAgentSocket(ip);
return socket.timeout(COMMAND_TIMEOUT_MS).emitWithAck(event, payload) as Promise<T>;
}
// Captures the Windows PC's screen. Returns a base64-encoded PNG for the app's AI to read.
export function captureScreenshot(ip?: string) {
return sendCommand<{ image: string }>("cmd:screenshot", {}, ip);
}
// Moves the cursor without clicking — lets a step take a confirmation screenshot before
// committing to a click.
export function moveMouse(x: number, y: number, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:move", { x, y }, ip);
}
export function click(x: number, y: number, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:click", { x, y }, ip);
}
export function doubleClick(x: number, y: number, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:double_click", { x, y }, ip);
}
// Double-clicks wherever the mouse already is — no coordinates needed. Used for the first
// step of a run, where staff pre-position the cursor before triggering it from the browser.
export function doubleClickCurrent(ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:double_click_current", {}, ip);
}
export function typeText(text: string, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:type", { text }, ip);
}
export function pressKey(key: string, ip?: string) {
return sendCommand<{ ok: boolean }>("cmd:key", { key }, ip);
}
// Forcibly closes a connected agent's socket — used by the "x" on the Type Agent page to
// let staff stop controlling a PC without touching the exe on that machine.
export function disconnectAgent(ip: string) {
const socket = getAgentSocket(ip);
socket.disconnect(true);
}
+58
View File
@@ -3,6 +3,22 @@ import { Server, Socket } from "socket.io";
let io: Server | null = null; let io: Server | null = null;
interface ConnectedAgent {
socketId: string;
ip: string;
connectedAt: string;
}
// Windows Agents (DentalAgent.exe) connected via the /agent namespace, keyed by socket id.
const connectedAgents = new Map<string, ConnectedAgent>();
export function getAgentStatus() {
return {
connected: connectedAgents.size > 0,
agents: Array.from(connectedAgents.values()),
};
}
export function initSocket(server: HttpServer) { export function initSocket(server: HttpServer) {
const NODE_ENV = ( const NODE_ENV = (
process.env.NODE_ENV || process.env.NODE_ENV ||
@@ -47,7 +63,49 @@ export function initSocket(server: HttpServer) {
console.error("Socket engine connection_error:", err); console.error("Socket engine connection_error:", err);
}); });
initAgentNamespace(io);
return io; return io;
} }
// Windows Agent (DentalAgent.exe) bridge. Separate namespace so browser clients on the
// default namespace never see agent traffic, and so we can gate it with a shared secret —
// this connection will eventually be able to click/type on a real front-desk PC.
function initAgentNamespace(server: Server) {
const agentNamespace = server.of("/agent");
const AGENT_TOKEN = process.env.WINDOWS_AGENT_TOKEN;
agentNamespace.use((socket, next) => {
if (!AGENT_TOKEN) {
// No token configured — refuse rather than silently accepting any connection.
return next(new Error("Windows Agent bridge is not configured (WINDOWS_AGENT_TOKEN unset)"));
}
if (socket.handshake.auth?.token !== AGENT_TOKEN) {
return next(new Error("Invalid agent token"));
}
next();
});
agentNamespace.on("connection", (socket: Socket) => {
// x-forwarded-for is only trustworthy behind a proxy we control (nginx in prod);
// falls back to the raw socket address for direct LAN connections in dev.
const ip = (socket.handshake.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|| socket.handshake.address;
connectedAgents.set(socket.id, {
socketId: socket.id,
ip,
connectedAt: new Date().toISOString(),
});
console.log("🖥️ Windows Agent connected:", socket.id, ip);
io?.emit("type-agent:status", getAgentStatus());
socket.on("disconnect", () => {
connectedAgents.delete(socket.id);
console.log("🖥️ Windows Agent disconnected:", socket.id);
io?.emit("type-agent:status", getAgentStatus());
});
});
}
export { io }; export { io };
@@ -6,6 +6,8 @@ import {
UpdateAppointmentProcedure, UpdateAppointmentProcedure,
} from "@repo/db/types"; } from "@repo/db/types";
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
import fs from "fs";
import path from "path";
export interface AppointmentFileMeta { export interface AppointmentFileMeta {
filename: string; filename: string;
@@ -46,6 +48,11 @@ export interface IAppointmentProceduresStorage {
deleteProcedure(id: number): Promise<void>; deleteProcedure(id: number): Promise<void>;
clearByAppointmentId(appointmentId: number): Promise<void>; clearByAppointmentId(appointmentId: number): Promise<void>;
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>; getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
addAppointmentFile(
appointmentId: number,
file: AppointmentFileMeta
): Promise<AppointmentFileMeta & { id: number }>;
deleteAppointmentFile(appointmentId: number, fileId: number): Promise<boolean>;
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>; getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>; getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
} }
@@ -201,4 +208,56 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
filePath: f.filePath, filePath: f.filePath,
})); }));
}, },
async addAppointmentFile(
appointmentId: number,
file: AppointmentFileMeta
): Promise<AppointmentFileMeta & { id: number }> {
const row = await db.appointmentFile.create({
data: {
appointmentId,
filename: file.filename,
mimeType: file.mimeType ?? null,
filePath: file.filePath ?? null,
},
});
return {
id: row.id,
filename: row.filename,
mimeType: row.mimeType,
filePath: row.filePath,
};
},
async deleteAppointmentFile(appointmentId: number, fileId: number): Promise<boolean> {
const file = await db.appointmentFile.findFirst({
where: { id: fileId, appointmentId },
});
if (!file) return false;
await db.appointmentFile.delete({ where: { id: fileId } });
// Remove any ClaimFile copies previously auto-linked from this appointment file
// so a deleted/replaced attachment doesn't linger on the claim and get re-uploaded.
await db.claimFile.deleteMany({
where: { filename: file.filename, claim: { appointmentId } },
});
// Best-effort cleanup of the underlying cloud-storage file/disk copy.
if (file.filePath) {
const cloudFile = await db.cloudFile.findFirst({
where: { diskPath: file.filePath },
select: { id: true, diskPath: true },
});
if (cloudFile) {
if (cloudFile.diskPath) {
const abs = path.join(process.cwd(), cloudFile.diskPath);
if (fs.existsSync(abs)) fs.unlinkSync(abs);
}
await db.cloudFile.delete({ where: { id: cloudFile.id } }).catch(() => null);
}
}
return true;
},
}; };
@@ -138,6 +138,7 @@ export interface IStorage {
streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise<void>; streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise<void>;
// Patient folder // Patient folder
getPatientFolder(patientId: number): Promise<CloudFolder | null>;
getOrCreatePatientFolder( getOrCreatePatientFolder(
userId: number, userId: number,
patientId: number, patientId: number,
@@ -161,6 +162,7 @@ export interface IStorage {
patientId: number, patientId: number,
category: string category: string
): Promise<CloudFile[]>; ): Promise<CloudFile[]>;
listRecentAttachmentFiles(limit?: number): Promise<CloudFile[]>;
} }
/* ------------------------------- Implementation ------------------------------- */ /* ------------------------------- Implementation ------------------------------- */
@@ -548,6 +550,13 @@ export const cloudStorageStorage: IStorage = {
}, },
// --- PATIENT FOLDER --- // --- PATIENT FOLDER ---
async getPatientFolder(patientId: number) {
const existing = await db.cloudFolder.findFirst({
where: { patientId },
});
return (existing as unknown as CloudFolder) ?? null;
},
async getOrCreatePatientFolder( async getOrCreatePatientFolder(
userId: number, userId: number,
patientId: number, patientId: number,
@@ -663,6 +672,25 @@ export const cloudStorageStorage: IStorage = {
return files.map(serializeFile) as unknown as CloudFile[]; return files.map(serializeFile) as unknown as CloudFile[];
}, },
async listRecentAttachmentFiles(limit: number = 20) {
const files = await db.cloudFile.findMany({
where: { folder: { name: "Attachments" }, isComplete: true },
orderBy: { createdAt: "desc" },
take: limit,
select: {
id: true,
name: true,
mimeType: true,
fileSize: true,
folderId: true,
isComplete: true,
createdAt: true,
updatedAt: true,
},
});
return files.map(serializeFile) as unknown as CloudFile[];
},
// --- STREAM --- // --- STREAM ---
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
const file = await db.cloudFile.findUnique({ const file = await db.cloudFile.findUnique({
@@ -11,6 +11,7 @@ export interface IStorage {
getPatient(id: number): Promise<Patient | undefined>; getPatient(id: number): Promise<Patient | undefined>;
getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>; getPatientByInsuranceId(insuranceId: string): Promise<Patient | null>;
getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>; getPatientByInsuranceIdAndDob(insuranceId: string, dob: Date): Promise<Patient | null>;
getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null>;
getAllPatients(): Promise<Patient[]>; getAllPatients(): Promise<Patient[]>;
getRecentPatients(limit: number, offset: number): Promise<Patient[]>; getRecentPatients(limit: number, offset: number): Promise<Patient[]>;
getPatientsByIds(ids: number[]): Promise<Patient[]>; getPatientsByIds(ids: number[]): Promise<Patient[]>;
@@ -74,6 +75,12 @@ export const patientsStorage: IStorage = {
}); });
}, },
async getPatientByInsuranceIdNoDob(insuranceId: string): Promise<Patient | null> {
return db.patient.findFirst({
where: { insuranceId, dateOfBirth: null },
});
},
async getRecentPatients(limit: number, offset: number): Promise<Patient[]> { async getRecentPatients(limit: number, offset: number): Promise<Patient[]> {
return db.patient.findMany({ return db.patient.findMany({
skip: offset, skip: offset,
@@ -1,5 +1,12 @@
import { prisma as db } from "@repo/db/client"; import { prisma as db } from "@repo/db/client";
// Exported so twilio-webhooks.ts can detect an admin-customized template
// (vs. this untouched default) and swap in a translated greeting instead.
export const DEFAULT_GREETING =
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
export const DEFAULT_RECALL_GREETING =
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning. Would you like to make an appointment because your insurance covers it?";
export type TwilioSettingsData = { export type TwilioSettingsData = {
accountSid: string; accountSid: string;
authToken: string; authToken: string;
@@ -96,6 +103,38 @@ export const twilioStorage = {
}); });
}, },
// ── Call Templates — wording spoken during live AI phone calls, kept
// separate from the SMS wording above (routePatientTurn() consumes
// whichever set the caller passes in). ────────────────────────────────────
async getAiCallTemplates(userId: number): Promise<Record<string, string>> {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const all = (settings?.templates as Record<string, string>) || {};
return {
greeting: all["_ai_call_greeting"] ?? all["_ai_chat_call_template"] ?? DEFAULT_GREETING,
reminderGreeting: all["_ai_call_reminder_greeting"] ?? "Sure, I can help with that.",
newPatientGreeting: all["_ai_call_new_patient_greeting"] ?? "No problem, let's get you set up.",
generalFallback: all["_ai_call_general_fallback"] ?? "Sure, how can I help?",
recallGreeting: all["_ai_call_recall_greeting"] ?? DEFAULT_RECALL_GREETING,
};
},
async saveAiCallTemplates(userId: number, templates: { greeting?: string; reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; recallGreeting?: string }) {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const existing = (settings?.templates as Record<string, string>) || {};
const updated: Record<string, string> = { ...existing };
if (templates.greeting !== undefined) updated["_ai_call_greeting"] = templates.greeting;
if (templates.reminderGreeting !== undefined) updated["_ai_call_reminder_greeting"] = templates.reminderGreeting;
if (templates.newPatientGreeting !== undefined) updated["_ai_call_new_patient_greeting"] = templates.newPatientGreeting;
if (templates.generalFallback !== undefined) updated["_ai_call_general_fallback"] = templates.generalFallback;
if (templates.recallGreeting !== undefined) updated["_ai_call_recall_greeting"] = templates.recallGreeting;
return db.twilioSettings.upsert({
where: { userId },
update: { templates: updated },
create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated },
});
},
async getSmsTemplateList(userId: number): Promise<{ id: string; name: string; body: string }[]> { async getSmsTemplateList(userId: number): Promise<{ id: string; name: string; body: string }[]> {
const settings = await db.twilioSettings.findUnique({ where: { userId } }); const settings = await db.twilioSettings.findUnique({ where: { userId } });
const all = (settings?.templates as Record<string, string>) || {}; const all = (settings?.templates as Record<string, string>) || {};
+33
View File
@@ -0,0 +1,33 @@
import type { Request } from "express";
/**
* Public HTTPS origin this request arrived on (e.g. https://broadwaydental.mydentalofficemanagement.com).
* Derived from the Host header rather than an env var so it's automatically correct
* per-office — each office's nginx forwards its own public hostname in Host,
* and Cloudflare/Let's Encrypt terminate TLS in front of it either way.
*
* Only valid for requests that actually arrived via the public tunnel (i.e. inside
* a Twilio webhook handler). For anything triggered from the staff browser — which
* reaches the backend over the LAN-only local-* hostname — use getTwilioPublicBaseUrl()
* instead, since Twilio can never reach that LAN hostname to fetch a callback URL.
*/
export function getPublicBaseUrl(req: Request): string {
return `https://${req.get("host")}`;
}
/**
* Fixed public origin Twilio must use to call back into this office's server
* (e.g. for outbound-call TwiML webhooks). Comes from CLOUDFLARE_HOST, the
* per-office env var already reserved for this — not derived from the
* triggering request, since that request may have arrived over the LAN-only
* hostname, which Twilio cannot reach.
*/
export function getTwilioPublicBaseUrl(): string {
const host = process.env.CLOUDFLARE_HOST?.trim();
if (!host) {
throw new Error(
"CLOUDFLARE_HOST is not set — required to build a Twilio-reachable callback URL. Set it in .env to this office's public tunnel hostname."
);
}
return `https://${host}`;
}
@@ -0,0 +1,56 @@
import fs from "fs";
import path from "path";
// Local-disk-only debug copy of screenshots captured via the Copy Agent page.
// Not exposed anywhere in the app UI — purely for finding raw captures on disk when
// diagnosing AI detection issues. Never blocks the calling request if it fails.
const BACKUP_ROOT = path.join(process.cwd(), "uploads", "screenshot-backups");
export function backupScreenshots(files: { originalname: string; buffer: Buffer }[]): void {
try {
const dateDir = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const dir = path.join(BACKUP_ROOT, dateDir);
fs.mkdirSync(dir, { recursive: true });
for (const file of files) {
const safeName = `${Date.now()}_${file.originalname.replace(/[/\\?%*:|"<>]/g, "-")}`;
fs.writeFileSync(path.join(dir, safeName), file.buffer);
}
} catch (err) {
console.error("[screenshotBackup] failed to write backup:", err);
}
}
function getTypeAgentRunDir(runId: string): string {
const dateDir = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(BACKUP_ROOT, "type-agent", dateDir, runId);
}
// Same convention as backupScreenshots, but grouped under a "type-agent/<date>/<runId>"
// subfolder so every screenshot from one Type Agent run stays together for debugging
// (e.g. "locate" and "confirm" images per step, in click order via the timestamp prefix).
export function backupTypeAgentScreenshot(runId: string, label: string, imageBase64: string): void {
try {
const dir = getTypeAgentRunDir(runId);
fs.mkdirSync(dir, { recursive: true });
const safeLabel = label.replace(/[/\\?%*:|"<>]/g, "-").slice(0, 80);
const fileName = `${Date.now()}_${safeLabel}.png`;
fs.writeFileSync(path.join(dir, fileName), Buffer.from(imageBase64, "base64"));
} catch (err) {
console.error("[screenshotBackup] failed to write type-agent backup:", err);
}
}
// One human-readable line per event (step start/attempt/locate/confirm/click/error), appended
// to run.log in the same per-run folder as that run's screenshots — so opening one folder
// shows both what was clicked and what it looked like at the time, including the exact
// AI-reported ratio for every locate attempt.
export function logTypeAgentStep(runId: string, entry: Record<string, unknown>): void {
try {
const dir = getTypeAgentRunDir(runId);
fs.mkdirSync(dir, { recursive: true });
const line = `[${new Date().toISOString()}] ${JSON.stringify(entry)}\n`;
fs.appendFileSync(path.join(dir, "run.log"), line);
} catch (err) {
console.error("[screenshotBackup] failed to write type-agent log:", err);
}
}
@@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Paperclip, Image as ImageIcon, Camera, Loader2, Check, RotateCcw, FileText, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiRequest } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { useScreenCapture } from "@/hooks/use-screen-capture";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
interface AppointmentAttachmentsProps {
appointmentId: number;
patientId?: number;
}
interface AttachmentItem {
localId: string;
filename: string;
status: "uploading" | "done" | "error";
file?: File;
fileId?: number;
}
async function fetchAppointmentAttachments(appointmentId: number): Promise<AttachmentItem[]> {
const res = await apiRequest("GET", `/api/appointments/${appointmentId}/files`);
if (!res.ok) return [];
const body = await res.json().catch(() => null);
const files: Array<{ id: number; filename: string }> = body?.data ?? [];
return files.map((f) => ({
localId: `existing-${f.id}`,
filename: f.filename,
status: "done" as const,
fileId: f.id,
}));
}
async function deleteAppointmentAttachment(appointmentId: number, fileId: number) {
const res = await apiRequest("DELETE", `/api/appointments/${appointmentId}/files/${fileId}`);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || body?.error || "Failed to delete attachment");
}
}
function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const canvas = document.createElement("canvas");
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
const ctx = canvas.getContext("2d");
ctx?.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL("image/png");
}
async function uploadAppointmentAttachment(
appointmentId: number,
patientId: number,
file: File
) {
const formData = new FormData();
formData.append("file", file);
formData.append("patientId", String(patientId));
const res = await apiRequest("POST", `/api/appointments/${appointmentId}/files`, formData);
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || body?.error || "Failed to upload attachment");
}
return res.json();
}
export function AppointmentAttachments({ appointmentId, patientId }: AppointmentAttachmentsProps) {
const [items, setItems] = useState<AttachmentItem[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { capture, isCapturing, countdown } = useScreenCapture();
// Crop dialog state, shown after a screenshot is captured
const [screenshotDataUrl, setScreenshotDataUrl] = useState<string | null>(null);
const [screenshotFileName, setScreenshotFileName] = useState("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
let cancelled = false;
if (appointmentId) {
void fetchAppointmentAttachments(appointmentId).then((existing) => {
if (!cancelled) setItems((prev) => [...existing, ...prev]);
});
}
return () => {
cancelled = true;
};
}, [appointmentId]);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
imgRef.current = e.currentTarget;
const { width, height } = e.currentTarget;
const fullCrop = centerCrop(
makeAspectCrop({ unit: "%", width: 100 }, width / height, width, height),
width,
height
);
setCrop(fullCrop);
setCompletedCrop({ unit: "px", x: 0, y: 0, width, height });
};
const closeCropDialog = () => {
setScreenshotDataUrl(null);
setScreenshotFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
const runUpload = async (file: File) => {
if (!patientId) {
toast({ title: "Missing patient", description: "Cannot attach files without a patient.", variant: "destructive" });
return;
}
const localId = crypto.randomUUID();
setItems((prev) => [...prev, { localId, filename: file.name, status: "uploading", file }]);
try {
const created = await uploadAppointmentAttachment(appointmentId, patientId, file);
setItems((prev) =>
prev.map((it) =>
it.localId === localId ? { ...it, status: "done", fileId: created?.data?.id } : it
)
);
} catch (err: any) {
setItems((prev) => prev.map((it) => (it.localId === localId ? { ...it, status: "error" } : it)));
toast({
title: "Upload failed",
description: err?.message ?? `Failed to upload ${file.name}.`,
variant: "destructive",
});
}
};
const retryUpload = (item: AttachmentItem) => {
if (!item.file) return;
setItems((prev) => prev.filter((it) => it.localId !== item.localId));
void runUpload(item.file);
};
const deleteItem = async (item: AttachmentItem) => {
if (!item.fileId) return;
const prevItems = items;
setItems((prev) => prev.filter((it) => it.localId !== item.localId));
try {
await deleteAppointmentAttachment(appointmentId, item.fileId);
} catch (err: any) {
setItems(prevItems);
toast({
title: "Delete failed",
description: err?.message ?? `Failed to delete ${item.filename}.`,
variant: "destructive",
});
}
};
const handleDiskFilesSelected = (files: FileList | null) => {
if (!files?.length) return;
Array.from(files).forEach((file) => void runUpload(file));
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleServerFilesSelected = (files: File[]) => {
setPickerOpen(false);
files.forEach((file) => void runUpload(file));
};
const handleScreenshot = async () => {
try {
const dataUrl = await capture();
setScreenshotDataUrl(dataUrl);
setScreenshotFileName(`screenshot_${Date.now()}`);
} catch (err: any) {
toast({
title: "Screenshot failed",
description: err?.message ?? "Screen capture failed.",
variant: "destructive",
});
}
};
const handleRetakeScreenshot = async () => {
closeCropDialog();
await handleScreenshot();
};
const handleSaveScreenshot = async () => {
if (!screenshotDataUrl) return;
const croppedDataUrl =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshotDataUrl;
const response = await fetch(croppedDataUrl);
const blob = await response.blob();
const safeName =
screenshotFileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
closeCropDialog();
void runUpload(file);
};
return (
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Attachments</span>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
title="Upload from disk"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Choose from scanned attachments"
onClick={() => setPickerOpen(true)}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
title="Take screenshot"
disabled={isCapturing}
onClick={handleScreenshot}
>
{isCapturing ? (
<span className="flex h-4 w-4 items-center justify-center text-xs">
{countdown ?? <Loader2 className="h-4 w-4 animate-spin" />}
</span>
) : (
<Camera className="h-4 w-4" />
)}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,application/pdf"
className="hidden"
onChange={(e) => handleDiskFilesSelected(e.target.files)}
/>
</div>
{items.length > 0 && (
<ul className="space-y-1">
{items.map((item) => (
<li key={item.localId} className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.filename}</span>
{item.status === "uploading" && <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />}
{item.status === "done" && <Check className="h-3.5 w-3.5 shrink-0 text-green-600" />}
{item.status === "error" && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
title="Retry upload"
onClick={() => retryUpload(item)}
>
<RotateCcw className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
{item.status === "done" && item.fileId != null && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
title="Delete attachment"
onClick={() => deleteItem(item)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</li>
))}
</ul>
)}
<ServerAttachmentsPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
patientId={patientId}
onFilesSelected={handleServerFilesSelected}
/>
<Dialog open={!!screenshotDataUrl} onOpenChange={(open) => !open && closeCropDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crop screenshot</DialogTitle>
</DialogHeader>
{screenshotDataUrl && (
<div className="space-y-3">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
>
<img
src={screenshotDataUrl}
alt="Captured screenshot"
onLoad={handleImageLoad}
className="w-full rounded-lg border object-contain max-h-96"
/>
</ReactCrop>
<p className="text-xs text-muted-foreground">
Drag the corners to crop, or leave as-is to attach the full screenshot.
</p>
<div className="space-y-1.5">
<Label htmlFor="appointment-screenshot-filename">File name</Label>
<div className="flex items-center gap-1">
<Input
id="appointment-screenshot-filename"
value={screenshotFileName}
onChange={(e) => setScreenshotFileName(e.target.value)}
placeholder="screenshot"
/>
<span className="text-sm text-muted-foreground">.png</span>
</div>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleRetakeScreenshot} disabled={isCapturing}>
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button type="button" onClick={handleSaveScreenshot}>
<Check className="h-4 w-4 mr-2" />
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -30,6 +30,7 @@ import {
Appointment, Appointment,
InsertAppointment, InsertAppointment,
insertAppointmentSchema, insertAppointmentSchema,
NpiProvider,
Patient, Patient,
Staff, Staff,
UpdateAppointment, UpdateAppointment,
@@ -37,6 +38,7 @@ import {
import { DateInputField } from "@/components/ui/dateInputField"; import { DateInputField } from "@/components/ui/dateInputField";
import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { AppointmentAttachments } from "@/components/appointments/appointment-attachments";
export interface NewAppointmentPrefill { export interface NewAppointmentPrefill {
staffId: number; staffId: number;
@@ -93,6 +95,15 @@ export function AppointmentForm({
enabled: !!user, enabled: !!user,
}); });
const { data: npiProviders = [] as NpiProvider[] } = useQuery<NpiProvider[]>({
queryKey: ["/api/npiProviders/"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/npiProviders/");
return res.json();
},
enabled: !!user,
});
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
"Dr. Kai Gao": "bg-blue-600", "Dr. Kai Gao": "bg-blue-600",
"Dr. Jane Smith": "bg-emerald-600", "Dr. Jane Smith": "bg-emerald-600",
@@ -119,6 +130,10 @@ export function AppointmentForm({
typeof appointment.staffId === "number" typeof appointment.staffId === "number"
? appointment.staffId ? appointment.staffId
: undefined, : undefined,
npiProviderId:
typeof appointment.npiProviderId === "number"
? appointment.npiProviderId
: undefined,
} }
: prefillData : prefillData
? { ? {
@@ -325,15 +340,6 @@ export function AppointmentForm({
setSelectOpen(open); setSelectOpen(open);
if (!open) { if (!open) {
setPatientSearchTerm(""); setPatientSearchTerm("");
if (
prefillPatient &&
patients &&
patients.some(
(p) => Number(p.id) === Number(prefillPatient.id)
)
) {
setPrefillPatient(null);
}
} else { } else {
if (!patients || patients.length === 0) refetchPatients(); if (!patients || patients.length === 0) refetchPatients();
} }
@@ -348,9 +354,15 @@ export function AppointmentForm({
? "" ? ""
: String(field.value) : String(field.value)
} }
onValueChange={(val) => onValueChange={(val) => {
field.onChange(val === "" ? undefined : Number(val)) field.onChange(val === "" ? undefined : Number(val));
} if (val !== "") {
const picked = patients.find(
(p) => Number(p.id) === Number(val)
);
if (picked) setPrefillPatient(picked);
}
}}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -616,6 +628,47 @@ export function AppointmentForm({
)} )}
/> />
<FormField
control={form.control}
name="npiProviderId"
render={({ field }) => (
<FormItem>
<FormLabel>
Provider{" "}
<span className="text-muted-foreground text-xs">
(used by AI to select the rendering provider on insurance claims)
</span>
</FormLabel>
<Select
disabled={isLoading}
onValueChange={(val) =>
field.onChange(val === "unassigned" ? undefined : Number(val))
}
value={field.value ? String(field.value) : "unassigned"}
defaultValue={field.value ? String(field.value) : "unassigned"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{npiProviders.map((provider) => (
<SelectItem
key={provider.id}
value={provider.id?.toString() || ""}
>
{provider.providerName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="notes" name="notes"
@@ -636,6 +689,10 @@ export function AppointmentForm({
)} )}
/> />
{appointment?.id && (
<AppointmentAttachments appointmentId={appointment.id} patientId={appointment.patientId} />
)}
<Button type="submit" disabled={isLoading} className="w-full"> <Button type="submit" disabled={isLoading} className="w-full">
{appointment ? "Update Appointment" : "Create Appointment"} {appointment ? "Update Appointment" : "Create Appointment"}
</Button> </Button>
@@ -463,10 +463,12 @@ export function ClaimForm({
}, [appointmentId]); }, [appointmentId]);
// 2b. Prefill procedures from AppointmentProcedure records. // 2b. Prefill procedures from AppointmentProcedure records.
// Skipped when an existing claim was already loaded above. // Service lines/NPI are skipped when an existing claim was already loaded above
// (claim data takes priority), but appointmentFiles must always be fetched here —
// otherwise existingAppointmentFiles stays empty and handleProceduresSave's merge
// logic silently wipes previously-attached files on the next save.
useEffect(() => { useEffect(() => {
if (!appointmentId) return; if (!appointmentId) return;
if (existingClaimId) return; // existing claim takes priority
let cancelled = false; let cancelled = false;
@@ -482,6 +484,12 @@ export function ClaimForm({
if (cancelled) return; if (cancelled) return;
if (data.appointmentFiles?.length) {
setExistingAppointmentFiles(data.appointmentFiles);
}
if (existingClaimId) return; // existing claim takes priority for the rest
const mappedLines = (data.procedures || []).map((p: any) => ({ const mappedLines = (data.procedures || []).map((p: any) => ({
procedureCode: p.procedureCode, procedureCode: p.procedureCode,
procedureDate: serviceDate, procedureDate: serviceDate,
@@ -527,16 +535,18 @@ export function ClaimForm({
}; };
}, [appointmentId, serviceDate, existingClaimId]); }, [appointmentId, serviceDate, existingClaimId]);
// Attach any file the user uploaded/screenshotted in the chatbot to this claim/preauth,
// regardless of whether the user typed anything like "with the attachment" — the mere
// act of attaching it in chat is enough intent.
useEffect(() => {
const chatbotFiles = takeChatbotPendingFiles();
if (chatbotFiles.length === 0) return;
setForm((prev) => ({ ...prev, uploadedFiles: chatbotFiles }));
}, []);
// Prefill service lines (and optional service date) from chatbot claim_only flow // Prefill service lines (and optional service date) from chatbot claim_only flow
useEffect(() => { useEffect(() => {
const raw = sessionStorage.getItem("chatbot_claim_prefill"); const raw = sessionStorage.getItem("chatbot_claim_prefill");
const chatbotFiles = takeChatbotPendingFiles();
if (!raw && chatbotFiles.length === 0) return;
if (chatbotFiles.length > 0) {
setForm((prev) => ({ ...prev, uploadedFiles: chatbotFiles }));
}
if (!raw) return; if (!raw) return;
try { try {
const { codes, serviceDate } = JSON.parse(raw) as { const { codes, serviceDate } = JSON.parse(raw) as {
@@ -781,13 +791,12 @@ export function ClaimForm({
const deriveInsuranceSiteKey = (provider: string | null | undefined): string => { const deriveInsuranceSiteKey = (provider: string | null | undefined): string => {
const p = (provider || "").toLowerCase().trim(); const p = (provider || "").toLowerCase().trim();
if (!p) return ""; if (!p) return "";
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MH"; if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) return "MH";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA"; if (p.includes("ddma") || p.includes("delta dental ma")) return "DDMA";
if (p.includes("delta ins") || p === "deltains") return "DeltaIns"; if (p.includes("delta ins") || p === "deltains") return "DeltaIns";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO"; if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO";
if (p.includes("cmsp")) return "CMSP";
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS"; if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR"; if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
if (p.includes("aetna")) return "Aetna"; if (p.includes("aetna")) return "Aetna";
@@ -900,8 +909,24 @@ export function ClaimForm({
const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null); const uploadZoneRef = useRef<MultipleFileUploadZoneHandle | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]); const [priceMismatches, setPriceMismatches] = useState<PriceMismatch[]>([]);
// AppointmentFile rows already attached to this appointment (from prefill), kept separate
// from claimFiles/uploadedFiles so handleProceduresSave can merge rather than replace them.
const [existingAppointmentFiles, setExistingAppointmentFiles] = useState<ClaimFileMeta[]>([]);
const pendingClaimAction = useRef<(() => void) | null>(null); const pendingClaimAction = useRef<(() => void) | null>(null);
// Merges attachments already saved on the appointment (e.g. via the Schedule editor) with
// any newly uploaded in this claim-form session, so Selenium submission always gets every
// file — not just ones uploaded through this form's own file picker in the current session.
const mergeWithExistingAttachments = useCallback(
(newFiles: ClaimFileMeta[]): ClaimFileMeta[] => {
const byFilename = new Map<string, ClaimFileMeta>();
for (const f of existingAppointmentFiles) byFilename.set(f.filename, f);
for (const f of newFiles) byFilename.set(f.filename, f);
return Array.from(byFilename.values());
},
[existingAppointmentFiles],
);
// NO validation here — the upload zone handles validation, toasts, max files, sizes, etc. // NO validation here — the upload zone handles validation, toasts, max files, sizes, etc.
const handleFilesChange = useCallback((files: File[]) => { const handleFilesChange = useCallback((files: File[]) => {
setForm((prev) => ({ ...prev, uploadedFiles: files })); setForm((prev) => ({ ...prev, uploadedFiles: files }));
@@ -965,9 +990,9 @@ export function ClaimForm({
} = f; } = f;
// Save uploaded files to disk (uploads/patients/<name>/) and record their paths // Save uploaded files to disk (uploads/patients/<name>/) and record their paths
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -997,6 +1022,11 @@ export function ClaimForm({
insuranceSiteKey: "MH", insuranceSiteKey: "MH",
claimId: createdClaim.id, claimId: createdClaim.id,
uploadedFiles, uploadedFiles,
// Only skip the backend's appointment-attachment fallback merge when this automated
// submission already supplied its own files — otherwise files saved directly on the
// appointment (e.g. a screenshot attached via the Schedule editor) would never reach
// the insurance upload at all.
skipAppointmentAttachmentMerge: !!autoSubmit && !!uploadedFiles?.length,
}); });
// 5. Close form // 5. Close form
@@ -1096,10 +1126,12 @@ export function ClaimForm({
const appointmentIdToUse = appointmentId ?? null; const appointmentIdToUse = appointmentId ?? null;
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = (uploadedFiles || []).map((file) => ({ const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
filename: file.name, (uploadedFiles || []).map((file) => ({
mimeType: file.type, filename: file.name,
})); mimeType: file.type,
})),
);
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1165,9 +1197,9 @@ export function ClaimForm({
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
// Upload files to server so we get local filePaths for Selenium // Upload files to server so we get local filePaths for Selenium
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1231,9 +1263,9 @@ export function ClaimForm({
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1297,9 +1329,9 @@ export function ClaimForm({
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = f;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1566,9 +1598,9 @@ export function ClaimForm({
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToSave } = form; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToSave } = form;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
// Find the npiProviderId matching the currently selected NPI provider // Find the npiProviderId matching the currently selected NPI provider
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
@@ -1617,10 +1649,17 @@ export function ClaimForm({
: null; : null;
try { try {
const attachments = form.uploadedFiles?.length const newAttachments = form.uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(form.uploadedFiles) ? await uploadAttachmentsToLocalFolder(form.uploadedFiles)
: []; : [];
// save-for-appointment replaces ALL AppointmentFile rows for this appointment, so we must
// include files already attached (e.g. via the appointment edit form) or they'd be wiped.
const byFilename = new Map<string, ClaimFileMeta>();
for (const f of existingAppointmentFiles) byFilename.set(f.filename, f);
for (const f of newAttachments) byFilename.set(f.filename, f);
const attachments = Array.from(byFilename.values());
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", { const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
appointmentId, appointmentId,
patientId, patientId,
@@ -1649,7 +1688,7 @@ export function ClaimForm({
} }
} }
const attachMsg = attachments.length ? ` and ${attachments.length} attachment(s)` : ""; const attachMsg = newAttachments.length ? ` and ${newAttachments.length} attachment(s)` : "";
const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : ""; const typeMsg = inferredType ? ` · Type → ${getAppointmentTypeLabel(inferredType)}` : "";
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` }); toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved${typeMsg}.` });
onClose(); onClose();
@@ -1703,9 +1742,9 @@ export function ClaimForm({
const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form; const { uploadedFiles, insuranceSiteKey, npiProvider, ...formToCreateClaim } = form;
const claimFilesMeta: ClaimFileMeta[] = uploadedFiles?.length const claimFilesMeta: ClaimFileMeta[] = mergeWithExistingAttachments(
? await uploadAttachmentsToLocalFolder(uploadedFiles) uploadedFiles?.length ? await uploadAttachmentsToLocalFolder(uploadedFiles) : [],
: []; );
const selectedNpiProviderId = npiProvider?.npiNumber const selectedNpiProviderId = npiProvider?.npiNumber
? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null ? npiProviders.find((p) => p.npiNumber === npiProvider.npiNumber)?.id ?? null
@@ -1968,7 +2007,6 @@ export function ClaimForm({
<SelectItem value="DeltaIns">Delta Ins</SelectItem> <SelectItem value="DeltaIns">Delta Ins</SelectItem>
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem> <SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
<SelectItem value="UnitedSCO">United SCO</SelectItem> <SelectItem value="UnitedSCO">United SCO</SelectItem>
<SelectItem value="CMSP">CMSP</SelectItem>
<SelectItem value="BCBS">BCBS</SelectItem> <SelectItem value="BCBS">BCBS</SelectItem>
<SelectItem value="UnitedAAPR">United AAPR</SelectItem> <SelectItem value="UnitedAAPR">United AAPR</SelectItem>
<SelectItem value="Aetna">Aetna</SelectItem> <SelectItem value="Aetna">Aetna</SelectItem>
@@ -2320,6 +2358,7 @@ export function ClaimForm({
isUploading={isUploading} isUploading={isUploading}
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp" acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
maxFiles={10} maxFiles={10}
patientId={patientId}
/> />
{form.uploadedFiles.length > 0 && ( {form.uploadedFiles.length > 0 && (
<ul className="text-sm text-gray-700 list-disc ml-6"> <ul className="text-sm text-gray-700 list-disc ml-6">
@@ -2555,7 +2594,6 @@ export function ClaimForm({
<SelectItem value="DeltaIns">Delta Ins</SelectItem> <SelectItem value="DeltaIns">Delta Ins</SelectItem>
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem> <SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
<SelectItem value="UnitedSCO">United SCO</SelectItem> <SelectItem value="UnitedSCO">United SCO</SelectItem>
<SelectItem value="CMSP">CMSP</SelectItem>
<SelectItem value="BCBS">BCBS</SelectItem> <SelectItem value="BCBS">BCBS</SelectItem>
<SelectItem value="UnitedAAPR">United AAPR</SelectItem> <SelectItem value="UnitedAAPR">United AAPR</SelectItem>
<SelectItem value="Aetna">Aetna</SelectItem> <SelectItem value="Aetna">Aetna</SelectItem>
@@ -2883,6 +2921,7 @@ export function ClaimForm({
isUploading={isUploading} isUploading={isUploading}
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp" acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
maxFiles={10} maxFiles={10}
patientId={patientId}
/> />
{form.uploadedFiles.length > 0 && ( {form.uploadedFiles.length > 0 && (
<ul className="text-sm text-gray-700 list-disc ml-6"> <ul className="text-sm text-gray-700 list-disc ml-6">
@@ -287,6 +287,12 @@ export default function ClaimsRecentTable({
color: "bg-red-100 text-red-800", color: "bg-red-100 text-red-800",
icon: <AlertCircle className="h-3 w-3 mr-1" />, icon: <AlertCircle className="h-3 w-3 mr-1" />,
}; };
case "PREAUTH":
return {
label: "Submitted",
color: "bg-blue-100 text-blue-800",
icon: <Clock className="h-3 w-3 mr-1" />,
};
default: default:
return { return {
label: status label: status
@@ -9,6 +9,7 @@ import { Upload, X, FilePlus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
export type MultipleFileUploadZoneHandle = { export type MultipleFileUploadZoneHandle = {
getFiles: () => File[]; getFiles: () => File[];
@@ -25,6 +26,9 @@ interface FileUploadZoneProps {
maxFileSizeMB?: number; maxFileSizeMB?: number;
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 } //OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
maxFileSizeByType?: Record<string, number>; maxFileSizeByType?: Record<string, number>;
// OPTIONAL: when set, shows a "Choose from Server" option listing this patient's
// previously saved Cloud Storage attachments (e.g. Copy Agent screenshots)
patientId?: number;
} }
export const MultipleFileUploadZone = forwardRef< export const MultipleFileUploadZone = forwardRef<
@@ -39,12 +43,14 @@ export const MultipleFileUploadZone = forwardRef<
maxFiles = 10, maxFiles = 10,
maxFileSizeMB = 10, // default fallback per-file size (MB) maxFileSizeMB = 10, // default fallback per-file size (MB)
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 } maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
patientId,
}, },
ref ref
) => { ) => {
const { toast } = useToast(); const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const parsedAccept = acceptedFileTypes const parsedAccept = acceptedFileTypes
@@ -222,7 +228,7 @@ export const MultipleFileUploadZone = forwardRef<
[onFilesChange] [onFilesChange]
); );
const handleFiles = (files: FileList | null) => { const handleFiles = (files: FileList | File[] | null) => {
if (!files) return; if (!files) return;
const newFiles = Array.from(files).filter(validateFile); const newFiles = Array.from(files).filter(validateFile);
@@ -439,19 +445,42 @@ export const MultipleFileUploadZone = forwardRef<
Or click to browse files Or click to browse files
</p> </p>
</div> </div>
<Button <div className="flex gap-2">
type="button" <Button
variant="default" type="button"
onClick={(e) => { variant="default"
e.stopPropagation(); onClick={(e) => {
handleBrowseClick(); e.stopPropagation();
}} handleBrowseClick();
> }}
Browse files >
</Button> Choose from This Computer
</Button>
{patientId ? (
<Button
type="button"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setIsServerPickerOpen(true);
}}
>
Choose from Server
</Button>
) : null}
</div>
</div> </div>
)} )}
</div> </div>
{patientId ? (
<ServerAttachmentsPickerModal
open={isServerPickerOpen}
onClose={() => setIsServerPickerOpen(false)}
patientId={patientId}
onFilesSelected={(files) => handleFiles(files)}
/>
) : null}
</div> </div>
); );
} }
@@ -0,0 +1,207 @@
import React, { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileText, File as FileIcon, Image as ImageIcon, Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type { CloudFile } from "@repo/db/types";
interface ServerAttachmentsPickerModalProps {
open: boolean;
onClose: () => void;
// When set, lists only this patient's saved attachments. When omitted, lists
// recently saved attachments across all patients (used where no patient is in scope yet).
patientId?: number;
onFilesSelected: (files: File[]) => void;
}
function fileIcon(mime: string | undefined | null) {
if (mime?.startsWith("image/")) return ImageIcon;
if (mime === "application/pdf") return FileText;
return FileIcon;
}
function AttachmentThumbnail({ file }: { file: CloudFile }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!file.mimeType?.startsWith("image/")) return;
let objectUrl: string | null = null;
let cancelled = false;
(async () => {
const res = await apiRequest("GET", `/api/cloud-storage/files/${file.id}/content`);
if (!res.ok || cancelled) return;
const blob = await res.blob();
objectUrl = URL.createObjectURL(blob);
if (!cancelled) setSrc(objectUrl);
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [file.id, file.mimeType]);
if (file.mimeType?.startsWith("image/")) {
return src ? (
<img src={src} alt={file.name} className="h-full w-full object-cover" />
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
);
}
const Icon = fileIcon(file.mimeType);
return <Icon className="h-8 w-8 text-muted-foreground" />;
}
export function ServerAttachmentsPickerModal({
open,
onClose,
patientId,
onFilesSelected,
}: ServerAttachmentsPickerModalProps) {
const { toast } = useToast();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [isAdding, setIsAdding] = useState(false);
const endpoint = patientId
? `/api/claims/patient-attachments?patientId=${patientId}`
: `/api/claims/recent-attachments?limit=40`;
const { data, isLoading, isError } = useQuery<CloudFile[]>({
queryKey: ["/api/claims/attachments-picker", patientId ?? "recent"],
queryFn: async () => {
const res = await apiRequest("GET", endpoint);
const json = await res.json();
if (!res.ok || json.error) {
throw new Error(json.message || "Failed to load attachments");
}
return (json.data ?? []) as CloudFile[];
},
enabled: open,
});
useEffect(() => {
if (!open) setSelectedIds(new Set());
}, [open]);
if (!open) return null;
const files = data ?? [];
const toggleSelected = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleAddSelected = async () => {
if (selectedIds.size === 0) return;
setIsAdding(true);
try {
const selectedFiles = files.filter((f) => selectedIds.has(f.id as number));
const built = await Promise.all(
selectedFiles.map(async (f) => {
const res = await apiRequest("GET", `/api/cloud-storage/files/${f.id}/content`);
const blob = await res.blob();
return new File([blob], f.name, { type: f.mimeType || blob.type });
})
);
onFilesSelected(built);
onClose();
} catch (err) {
toast({
title: "Failed to add files",
description: "Could not fetch one or more attachments from the server.",
variant: "destructive",
});
} finally {
setIsAdding(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
if (!isAdding) onClose();
}}
/>
<div className="relative w-full max-w-2xl mx-4 bg-white rounded-lg shadow-lg flex flex-col max-h-[80vh]">
<div className="p-4 border-b">
<h3 className="text-lg font-medium">Choose from Server Attachments</h3>
<p className="text-sm text-muted-foreground mt-1">
{patientId
? "Previously saved screenshots and files for this patient."
: "Recently saved screenshots and files across all patients."}
</p>
</div>
<div className="p-4 overflow-y-auto flex-1" style={{ minHeight: "200px" }}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : isError ? (
<p className="text-sm text-red-500 text-center py-8">
Failed to load attachments.
</p>
) : files.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{patientId
? "No screenshots saved yet for this patient."
: "No screenshots saved yet."}
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{files.map((file) => {
const isSelected = selectedIds.has(file.id as number);
return (
<button
type="button"
key={file.id}
onClick={() => toggleSelected(file.id as number)}
className={`relative border rounded-md overflow-hidden text-left ${
isSelected ? "ring-2 ring-primary border-primary" : "hover:bg-muted/40"
}`}
>
<div className="h-24 flex items-center justify-center bg-gray-50">
<AttachmentThumbnail file={file} />
</div>
<div className="p-2">
<p className="text-xs font-medium truncate">{file.name}</p>
</div>
{isSelected && (
<div className="absolute top-1 right-1 bg-primary text-primary-foreground rounded-full p-0.5">
<Check className="h-3 w-3" />
</div>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t">
<Button variant="ghost" type="button" onClick={onClose} disabled={isAdding}>
Cancel
</Button>
<Button
type="button"
onClick={handleAddSelected}
disabled={selectedIds.size === 0 || isAdding}
>
{isAdding ? "Adding..." : `Add Selected (${selectedIds.size})`}
</Button>
</div>
</div>
</div>
);
}
+446 -57
View File
@@ -11,13 +11,57 @@ import {
Loader2, Loader2,
RotateCcw, RotateCcw,
Paperclip, Paperclip,
Image as ImageIcon,
Camera,
Check,
} from "lucide-react"; } from "lucide-react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore"; import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
import { useScreenCapture } from "@/hooks/use-screen-capture";
function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
const canvas = document.createElement("canvas");
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
const ctx = canvas.getContext("2d");
ctx?.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL("image/png");
}
type Step = type Step =
| "menu" | "menu"
@@ -75,13 +119,33 @@ function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message
return { id: ++msgCounter, role, text, isLoading }; return { id: ++msgCounter, role, text, isLoading };
} }
function getAutoCheck(dobISO: string): "mh" | "cmsp" { // eligibilityIdData.dob is stored/sent as ISO ("YYYY-MM-DD") everywhere downstream (patient
const [y, m, d] = dobISO.split("-").map(Number); // sync, sessionStorage prefill, matching) — these only convert for display/editing.
const today = new Date(); function isoToMDY(iso: string): string {
let age = today.getFullYear() - (y ?? 0); const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const monthDiff = today.getMonth() + 1 - (m ?? 0); if (!m) return iso;
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < (d ?? 0))) age--; return `${m[2]}/${m[3]}/${m[1]}`;
return age >= 21 ? "mh" : "cmsp"; }
function mdyToISO(mdy: string): string {
const m = mdy.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (!m) return mdy;
return `${m[3]}-${m[1]!.padStart(2, "0")}-${m[2]!.padStart(2, "0")}`;
}
const INSURANCE_TYPE_OPTIONS: { value: string; label: string }[] = [
{ value: "mh", label: "MassHealth" },
{ value: "ddma", label: "DDMA" },
{ value: "delta-ins", label: "Delta Dental" },
{ value: "united-sco", label: "United SCO" },
{ value: "tufts-sco", label: "Tufts SCO" },
{ value: "cca", label: "CCA" },
];
function getAutoCheck(_dobISO: string): "mh" {
// CMSP and MassHealth eligibility checks now run through the same MH worker,
// so there's no longer an age-based split — everyone is "mh".
return "mh";
} }
function parseEligibilityInput( function parseEligibilityInput(
@@ -163,6 +227,9 @@ export function ChatbotButton() {
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null); const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
const [freeTextInput, setFreeTextInput] = useState(""); const [freeTextInput, setFreeTextInput] = useState("");
const [patientResult, setPatientResult] = useState<PatientResult | null>(null); const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
// Snapshot of insuranceId/dateOfBirth as originally fetched, so we can tell whether the
// user hand-edited them in the patient-found card and only PUT to the DB when they did.
const originalPatientResultRef = useRef<{ insuranceId: string | null; dateOfBirth: string | null } | null>(null);
const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null); const [eligibilityIdData, setEligibilityIdData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null; appointmentDate?: string | null } | null>(null);
const [batchEligibilityData, setBatchEligibilityData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[] | null>(null); const [batchEligibilityData, setBatchEligibilityData] = useState<{ memberId: string; dob: string; siteKey: string; autoCheck: string; patient: PatientResult | null }[] | null>(null);
const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null); const [checkAndClaimData, setCheckAndClaimData] = useState<CheckAndClaimData | null>(null);
@@ -204,11 +271,70 @@ export function ChatbotButton() {
renderingProvider: string | null; renderingProvider: string | null;
} | null>(null); } | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
const { capture, isCapturing, countdown } = useScreenCapture();
const [screenshotDataUrl, setScreenshotDataUrl] = useState<string | null>(null);
const [screenshotFileName, setScreenshotFileName] = useState("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const screenshotImgRef = useRef<HTMLImageElement | null>(null);
const closeScreenshotDialog = () => {
setScreenshotDataUrl(null);
setScreenshotFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
const handleScreenshotImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
screenshotImgRef.current = e.currentTarget;
const { width, height } = e.currentTarget;
const fullCrop = centerCrop(
makeAspectCrop({ unit: "%", width: 100 }, width / height, width, height),
width,
height
);
setCrop(fullCrop);
setCompletedCrop({ unit: "px", x: 0, y: 0, width, height });
};
const handleTakeScreenshot = async () => {
try {
const dataUrl = await capture();
setScreenshotDataUrl(dataUrl);
setScreenshotFileName(`screenshot_${Date.now()}`);
} catch (err: any) {
addMsg("bot", err?.message ?? "Screen capture failed.");
}
};
const handleRetakeScreenshot = async () => {
closeScreenshotDialog();
await handleTakeScreenshot();
};
const handleAttachScreenshot = async () => {
if (!screenshotDataUrl) return;
const croppedDataUrl =
completedCrop && screenshotImgRef.current
? getCroppedDataUrl(screenshotImgRef.current, completedCrop)
: screenshotDataUrl;
const response = await fetch(croppedDataUrl);
const blob = await response.blob();
const safeName =
screenshotFileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
closeScreenshotDialog();
setPendingFiles((prev) => [...prev, file].slice(0, 5));
};
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const pasteRef = useRef<HTMLTextAreaElement>(null); const pasteRef = useRef<HTMLTextAreaElement>(null);
const freeTextRef = useRef<HTMLTextAreaElement>(null); const freeTextRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Set by the Copy Agent page's "AI Detect & Check Eligibility" flow when the screenshots were
// saved under a placeholder folder name; renamed to the real patient name once the user confirms.
const pendingFolderRenameRef = useRef<{ patientId: number; patientName: string } | null>(null);
// Load chat history from server on first mount (server is source of truth) // Load chat history from server on first mount (server is source of truth)
const serverLoaded = useRef(false); const serverLoaded = useRef(false);
@@ -234,6 +360,59 @@ export function ChatbotButton() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, step]); }, [messages, step]);
// Opened externally by the Copy Agent page after it saves screenshots and runs AI
// Member ID/DOB extraction — lands the chatbot directly on the confirm step.
useEffect(() => {
function handleOpenEligibilityIdReady(e: Event) {
const detail = (e as CustomEvent).detail as {
memberId: string | null;
dob: string | null;
autoCheck: string;
patient: PatientResult | null;
patientId?: number;
pendingFolderPatientName?: string;
};
if (detail.patientId && detail.pendingFolderPatientName) {
pendingFolderRenameRef.current = {
patientId: detail.patientId,
patientName: detail.pendingFolderPatientName,
};
}
if (shouldAutoReset()) resetStep();
setOpen(true);
if (detail.memberId && detail.dob) {
setEligibilityIdData({
memberId: detail.memberId,
dob: detail.dob,
siteKey: "",
autoCheck: detail.autoCheck || "mh",
patient: detail.patient,
appointmentDate: null,
});
addMsg(
"bot",
`I read this from your screenshots — ready to check ${detail.autoCheck === "tufts-sco" ? "Tufts SCO" : detail.autoCheck === "mh" ? "MassHealth" : "eligibility"} for ${
detail.patient ? `${detail.patient.firstName} ${detail.patient.lastName}` : detail.memberId
}. Confirm to proceed.`
);
setStep("eligibility-id-ready");
} else {
setPasteInput([detail.memberId, detail.dob].filter(Boolean).join(" "));
addMsg(
"bot",
"I saved your screenshots but couldn't fully read the Member ID/DOB. Please review and complete the fields below:"
);
setStep("eligibility-input");
}
}
window.addEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
return () => window.removeEventListener("chatbot:open-eligibility-id-ready", handleOpenEligibilityIdReady);
}, []);
// Persist messages to sessionStorage AND server // Persist messages to sessionStorage AND server
useEffect(() => { useEffect(() => {
try { try {
@@ -281,6 +460,7 @@ export function ChatbotButton() {
setBatchClaimData(null); setBatchClaimData(null);
setBatchCheckAndClaimData(null); setBatchCheckAndClaimData(null);
setPreauthReadyData(null); setPreauthReadyData(null);
pendingFolderRenameRef.current = null;
}; };
// Full reset including message history and stored session // Full reset including message history and stored session
@@ -343,30 +523,41 @@ export function ChatbotButton() {
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
}; };
const handleEligibilityFromPatient = () => { // If the user hand-edited Member ID / DOB on the patient-found card, save the correction
// back to the patient record so it doesn't revert to the stale value next time.
const persistPatientEditsIfChanged = async (patient: PatientResult) => {
const original = originalPatientResultRef.current;
if (!original) return;
const changed =
patient.insuranceId !== original.insuranceId || patient.dateOfBirth !== original.dateOfBirth;
if (!changed) return;
try {
await apiRequest("PUT", `/api/patients/${patient.id}`, {
insuranceId: patient.insuranceId,
dateOfBirth: patient.dateOfBirth,
});
originalPatientResultRef.current = { insuranceId: patient.insuranceId, dateOfBirth: patient.dateOfBirth };
} catch {
// Non-fatal — the edited values are still used for this eligibility check below.
}
};
const handleEligibilityFromPatient = async () => {
if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return; if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return;
addMsg("user", "Check eligibility now"); addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page..."); addMsg("bot", "Opening the eligibility check page...");
await persistPatientEditsIfChanged(patientResult);
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth)); prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
}; };
const handleEligibilityAndAppointmentFromPatient = async () => { const handleEligibilityAndAppointmentFromPatient = async () => {
if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return; if (!patientResult?.insuranceId || !patientResult?.dateOfBirth) return;
addMsg("user", "Check eligibility & add to schedule (today)"); addMsg("user", "Check eligibility & add to schedule (today)");
addMsg("bot", "Creating appointment for today...", true); addMsg("bot", "Running eligibility check — will add to today's schedule after...");
try { await persistPatientEditsIfChanged(patientResult);
const res = await apiRequest("POST", "/api/ai/create-appointment-today", { // Create the appointment only after eligibility is confirmed, not before — the eligibility
patientId: patientResult.id, // page's tryAppointmentFromChatbot picks this up once the check running below completes.
}); sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: patientResult.insuranceId, date: null }));
const data = await res.json();
if (!res.ok) {
replaceLastMsg(data.message ?? "Could not create appointment.");
return;
}
replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) — opening eligibility check page...`);
} catch {
replaceLastMsg("Could not create appointment — opening eligibility check page...");
}
prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth)); prefillAndNavigate(patientResult.insuranceId, patientResult.dateOfBirth, getAutoCheck(patientResult.dateOfBirth));
}; };
@@ -374,13 +565,39 @@ export function ChatbotButton() {
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck })); sessionStorage.setItem("chatbot_eligibility", JSON.stringify({ memberId, dob: dobISO, autoCheck }));
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill")); window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
markJobStarted(); markJobStarted();
if (pendingFolderRenameRef.current) {
const { patientId, patientName } = pendingFolderRenameRef.current;
pendingFolderRenameRef.current = null;
apiRequest("PATCH", `/api/claims/patient-folder/${patientId}/confirm-name`, { patientName }).catch(() => {});
}
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600); setTimeout(() => { setLocation("/insurance-status"); setOpen(false); resetStep(); }, 600);
}; };
const handleEligibilityIdRun = () => { // If the user hand-edited the Member ID/DOB on the "eligibility-id-ready" card (e.g. fixing a
// misread digit), push the correction to the patient record too — otherwise the real eligibility
// check (which matches by insuranceId+dob) would create a second, disconnected patient instead
// of updating this one. Surface a failure in the chat rather than swallowing it, since a silent
// failure here means the check proceeds against a stale record and silently creates a duplicate.
const syncEditedPatientFields = async (memberId: string, dob: string, patientId?: number): Promise<boolean> => {
if (!patientId) return true;
try {
await apiRequest("PUT", `/api/patients/${patientId}`, { insuranceId: memberId, dateOfBirth: dob });
return true;
} catch (err: any) {
console.error("Failed to sync edited Member ID/DOB to patient record:", err);
addMsg(
"bot",
`⚠️ Couldn't save the edited Member ID/DOB to the patient record (${err?.message ?? "unknown error"}). The eligibility check may create a duplicate patient — check the Patients page after.`
);
return false;
}
};
const handleEligibilityIdRun = async () => {
if (!eligibilityIdData) return; if (!eligibilityIdData) return;
addMsg("user", "Check eligibility now"); addMsg("user", "Check eligibility now");
addMsg("bot", "Opening the eligibility check page..."); addMsg("bot", "Opening the eligibility check page...");
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
}; };
@@ -413,30 +630,12 @@ export function ChatbotButton() {
? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) ? new Date(targetDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "today"; : "today";
addMsg("user", `Check eligibility & add to schedule (${dateLabel})`); addMsg("user", `Check eligibility & add to schedule (${dateLabel})`);
await syncEditedPatientFields(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.patient?.id);
if (!eligibilityIdData.patient) { addMsg("bot", `Running eligibility check — will add${eligibilityIdData.patient ? "" : " patient and"} to schedule for ${dateLabel} after...`);
addMsg("bot", `Running eligibility check — will add patient and create appointment for ${dateLabel} after...`); // Create the appointment only after eligibility is confirmed, not before — the eligibility
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null })); // page's tryAppointmentFromChatbot picks this up once the check running below completes.
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck); sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
return; prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
}
addMsg("bot", `Creating appointment for ${dateLabel}...`, true);
try {
const res = await apiRequest("POST", "/api/ai/create-appointment-today", {
patientId: eligibilityIdData.patient.id,
date: targetDate ?? undefined,
});
const data = await res.json();
if (!res.ok) {
replaceLastMsg(data.message ?? "Could not create appointment.");
return;
}
replaceLastMsg(`Appointment added at ${data.startTime} (${data.column ?? "Column A"}) for ${data.dateLabel} — opening eligibility check page...`);
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
} catch {
replaceLastMsg("Could not create appointment. Please try again.");
}
}; };
const handleCheckAndClaimRun = () => { const handleCheckAndClaimRun = () => {
@@ -460,9 +659,56 @@ export function ChatbotButton() {
prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck); prefillAndNavigate(checkAndClaimData.memberId, checkAndClaimData.dob, checkAndClaimData.autoCheck);
}; };
// Screenshot(s) attached with no typed text: same intent as the Copy Agent's
// "AI Detect & Check Eligibility" button — read Member ID/DOB off the image(s).
const handleScreenshotOnlyDetect = async () => {
const files = pendingFiles;
if (!files.length || step === "ai-loading") return;
setPendingFiles([]);
addMsg("user", `📎 ${files.length} screenshot${files.length > 1 ? "s" : ""}`);
addMsg("bot", "Reading your screenshot...", true);
setStep("ai-loading");
try {
const formData = new FormData();
for (const file of files) formData.append("files", file);
const res = await apiRequest("POST", "/api/ai/detect-eligibility-info", formData);
const json = await res.json();
if (json?.error) throw new Error(json.message || "Detection failed");
const memberId: string | null = json?.data?.memberId ?? null;
const dob: string | null = json?.data?.dob ?? null;
const autoCheck: string = json?.data?.autoCheck ?? "mh";
if (memberId && dob) {
setEligibilityIdData({
memberId,
dob,
siteKey: "",
autoCheck,
patient: null,
appointmentDate: null,
});
replaceLastMsg(`I read this from your screenshot — ready to check ${autoCheck === "tufts-sco" ? "Tufts SCO" : "MassHealth"} for ${memberId}. Confirm to proceed.`);
setStep("eligibility-id-ready");
} else {
setPasteInput([memberId, dob].filter(Boolean).join(" "));
replaceLastMsg("I saved your screenshot but couldn't fully read the Member ID/DOB. Please review and complete the fields below:");
setStep("eligibility-input");
}
} catch {
replaceLastMsg("Sorry, I couldn't read that screenshot. Please try again.");
setStep("menu");
}
};
const handleFreeTextSubmit = async () => { const handleFreeTextSubmit = async () => {
const text = freeTextInput.trim(); const text = freeTextInput.trim();
if (!text || step === "ai-loading") return; if (step === "ai-loading") return;
if (!text) {
if (pendingFiles.length > 0) return handleScreenshotOnlyDetect();
return;
}
setFreeTextInput(""); setFreeTextInput("");
addMsg("user", text); addMsg("user", text);
addMsg("bot", "Thinking...", true); addMsg("bot", "Thinking...", true);
@@ -495,6 +741,10 @@ export function ChatbotButton() {
data.actionData?.patient data.actionData?.patient
) { ) {
setPatientResult(data.actionData.patient); setPatientResult(data.actionData.patient);
originalPatientResultRef.current = {
insuranceId: data.actionData.patient.insuranceId,
dateOfBirth: data.actionData.patient.dateOfBirth,
};
setStep("patient-found"); setStep("patient-found");
return; return;
} }
@@ -814,12 +1064,29 @@ export function ChatbotButton() {
{patientResult.insuranceProvider && ( {patientResult.insuranceProvider && (
<p className="text-xs text-blue-600">{patientResult.insuranceProvider}</p> <p className="text-xs text-blue-600">{patientResult.insuranceProvider}</p>
)} )}
{patientResult.insuranceId && ( <div className="space-y-1">
<p className="text-xs text-gray-500">ID: {patientResult.insuranceId}</p> <Label className="text-[10px] text-gray-500">Member ID</Label>
)} <Input
{patientResult.dateOfBirth && ( value={patientResult.insuranceId ?? ""}
<p className="text-xs text-gray-500">DOB: {patientResult.dateOfBirth}</p> onChange={(e) =>
)} setPatientResult((prev) => (prev ? { ...prev, insuranceId: e.target.value } : prev))
}
className="h-7 text-xs bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
<Input
value={patientResult.dateOfBirth ? isoToMDY(patientResult.dateOfBirth) : ""}
onChange={(e) =>
setPatientResult((prev) =>
prev ? { ...prev, dateOfBirth: mdyToISO(e.target.value) } : prev
)
}
placeholder="MM/DD/YYYY"
className="h-7 text-xs bg-white"
/>
</div>
<div className="flex flex-col gap-2 pt-1"> <div className="flex flex-col gap-2 pt-1">
{patientResult.insuranceId && patientResult.dateOfBirth && ( {patientResult.insuranceId && patientResult.dateOfBirth && (
<> <>
@@ -856,8 +1123,49 @@ export function ChatbotButton() {
{eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName} {eligibilityIdData.patient.firstName} {eligibilityIdData.patient.lastName}
</p> </p>
)} )}
<p className="text-xs text-blue-600">ID: {eligibilityIdData.memberId}</p> <div className="space-y-1">
<p className="text-xs text-gray-500">DOB: {eligibilityIdData.dob}</p> <Label className="text-[10px] text-gray-500">Member ID</Label>
<Input
value={eligibilityIdData.memberId}
onChange={(e) =>
setEligibilityIdData((prev) => (prev ? { ...prev, memberId: e.target.value } : prev))
}
className="h-7 text-xs bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Date of Birth (MM/DD/YYYY)</Label>
<Input
value={isoToMDY(eligibilityIdData.dob)}
onChange={(e) =>
setEligibilityIdData((prev) =>
prev ? { ...prev, dob: mdyToISO(e.target.value) } : prev
)
}
placeholder="MM/DD/YYYY"
className="h-7 text-xs bg-white"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-500">Insurance Type</Label>
<Select
value={eligibilityIdData.autoCheck}
onValueChange={(value) =>
setEligibilityIdData((prev) => (prev ? { ...prev, autoCheck: value } : prev))
}
>
<SelectTrigger className="h-7 text-xs bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INSURANCE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{eligibilityIdData.patient?.insuranceProvider && ( {eligibilityIdData.patient?.insuranceProvider && (
<p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p> <p className="text-xs text-gray-500">{eligibilityIdData.patient.insuranceProvider}</p>
)} )}
@@ -1490,11 +1798,37 @@ export function ChatbotButton() {
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
<Button
size="sm"
variant="ghost"
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
title="Choose from scanned attachments"
onClick={() => setIsServerPickerOpen(true)}
disabled={step === "ai-loading"}
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
title="Take screenshot"
onClick={handleTakeScreenshot}
disabled={step === "ai-loading" || isCapturing}
>
{isCapturing ? (
<span className="flex h-4 w-4 items-center justify-center text-xs">
{countdown ?? <Loader2 className="h-4 w-4 animate-spin" />}
</span>
) : (
<Camera className="h-4 w-4" />
)}
</Button>
<Button <Button
size="sm" size="sm"
className="h-9 w-9 p-0 shrink-0" className="h-9 w-9 p-0 shrink-0"
onClick={handleFreeTextSubmit} onClick={handleFreeTextSubmit}
disabled={!freeTextInput.trim() || step === "ai-loading"} disabled={(!freeTextInput.trim() && pendingFiles.length === 0) || step === "ai-loading"}
> >
{step === "ai-loading" ? ( {step === "ai-loading" ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -1518,6 +1852,61 @@ export function ChatbotButton() {
e.target.value = ""; e.target.value = "";
}} }}
/> />
<ServerAttachmentsPickerModal
open={isServerPickerOpen}
onClose={() => setIsServerPickerOpen(false)}
onFilesSelected={(files) =>
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
}
/>
<Dialog open={!!screenshotDataUrl} onOpenChange={(open) => !open && closeScreenshotDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crop screenshot</DialogTitle>
</DialogHeader>
{screenshotDataUrl && (
<div className="space-y-3">
<ReactCrop
crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)}
onComplete={(pixelCrop) => setCompletedCrop(pixelCrop)}
>
<img
src={screenshotDataUrl}
alt="Captured screenshot"
onLoad={handleScreenshotImageLoad}
className="w-full rounded-lg border object-contain max-h-96"
/>
</ReactCrop>
<p className="text-xs text-muted-foreground">
Drag the corners to crop, or leave as-is to attach the full screenshot.
</p>
<div className="space-y-1.5">
<Label htmlFor="chatbot-screenshot-filename">File name</Label>
<div className="flex items-center gap-1">
<Input
id="chatbot-screenshot-filename"
value={screenshotFileName}
onChange={(e) => setScreenshotFileName(e.target.value)}
placeholder="screenshot"
/>
<span className="text-sm text-muted-foreground">.png</span>
</div>
</div>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleRetakeScreenshot} disabled={isCapturing}>
<RotateCcw className="h-4 w-4 mr-2" />
Retake
</Button>
<Button type="button" onClick={handleAttachScreenshot}>
<Check className="h-4 w-4 mr-2" />
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
)} )}
</div> </div>
@@ -311,7 +311,7 @@ export function Sidebar() {
icon: <Bot className="h-4 w-4 text-gray-400" />, icon: <Bot className="h-4 w-4 text-gray-400" />,
}, },
{ {
name: "AI Chat Settings", name: "AI Chat/Call Settings",
path: "/settings/aichat", path: "/settings/aichat",
icon: <Bot className="h-4 w-4 text-gray-400" />, icon: <Bot className="h-4 w-4 text-gray-400" />,
}, },
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX } from "lucide-react"; import { Send, ArrowLeft, FileText, Globe, Bot, UserPlus, CalendarX, Phone } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { Patient, Communication } from "@repo/db/types"; import type { Patient, Communication } from "@repo/db/types";
import { format, isToday, isYesterday, parseISO } from "date-fns"; import { format, isToday, isYesterday, parseISO } from "date-fns";
@@ -534,6 +534,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
{patient.firstName[0]}{patient.lastName[0]} {patient.firstName[0]}{patient.lastName[0]}
</div> </div>
<div> <div>
{comm.channel === "voice" && (
<div className="flex items-center gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md"> <div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p> <p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div> </div>
@@ -545,6 +551,12 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea
)} )}
{comm.direction === "outbound" && ( {comm.direction === "outbound" && (
<div> <div>
{comm.channel === "voice" && (
<div className="flex items-center justify-end gap-1 mb-1 text-[10px] font-medium text-muted-foreground">
<Phone className="h-3 w-3" />
Voice call
</div>
)}
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md"> <div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
<p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p> <p className="text-sm whitespace-pre-wrap break-words">{comm.body}</p>
</div> </div>
@@ -1,6 +1,7 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useToast } from "@/hooks/use-toast";
import { import {
Form, Form,
FormControl, FormControl,
@@ -50,6 +51,7 @@ export type PatientFormRef = {
export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>( export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
({ patient, extractedInfo, onSubmit }, ref) => { ({ patient, extractedInfo, onSubmit }, ref) => {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast();
const isEditing = !!patient; const isEditing = !!patient;
const schema = useMemo( const schema = useMemo(
@@ -62,13 +64,12 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
const normalizeInsuranceProvider = (val: string | null | undefined): string => { const normalizeInsuranceProvider = (val: string | null | undefined): string => {
const p = (val || "").toLowerCase().trim(); const p = (val || "").toLowerCase().trim();
if (p.includes("masshealth") || p === "mh" || p === "mass health") return "MassHealth"; if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) return "MassHealth";
if (p.includes("commonwealth care alliance") || p === "cca") return "CCA"; if (p.includes("commonwealth care alliance") || p === "cca") return "CCA";
if (p.includes("ddma")) return "DDMA"; if (p.includes("ddma")) return "DDMA";
if (p.includes("delta dental") || p.includes("delta ins") || p === "deltains") return "DeltaIns"; if (p.includes("delta dental") || p.includes("delta ins") || p === "deltains") return "DeltaIns";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO"; if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO"; if (p.includes("united sco") || p === "unitedsco") return "UnitedSCO";
if (p.includes("cmsp")) return "CMSP";
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS"; if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR"; if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
if (p.includes("aetna")) return "Aetna"; if (p.includes("aetna")) return "Aetna";
@@ -83,7 +84,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
const computedDefaultValues = useMemo(() => { const computedDefaultValues = useMemo(() => {
if (isEditing && patient) { if (isEditing && patient) {
const { id, userId, createdAt, ...sanitizedPatient } = patient; const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient;
return { return {
...sanitizedPatient, ...sanitizedPatient,
dateOfBirth: patient.dateOfBirth dateOfBirth: patient.dateOfBirth
@@ -137,7 +138,7 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
useEffect(() => { useEffect(() => {
if (patient) { if (patient) {
const { id, userId, createdAt, ...sanitizedPatient } = patient; const { id, userId, createdAt, updatedAt, ...sanitizedPatient } = patient;
const normalized = normalizeInsuranceProvider(patient.insuranceProvider); const normalized = normalizeInsuranceProvider(patient.insuranceProvider);
const resetValues: Partial<Patient> = { const resetValues: Partial<Patient> = {
...sanitizedPatient, ...sanitizedPatient,
@@ -164,9 +165,19 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
id="patient-form" id="patient-form"
key={patient?.id || "new"} key={patient?.id || "new"}
noValidate noValidate
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit(
handleSubmit2(data); (data) => {
})} handleSubmit2(data);
},
(errors) => {
console.log("❌ Form validation errors:", errors);
toast({
title: "Could not save patient",
description: "Please check the highlighted fields and try again.",
variant: "destructive",
});
},
)}
className="space-y-6" className="space-y-6"
> >
{/* Personal Information */} {/* Personal Information */}
@@ -439,7 +450,6 @@ export const PatientForm = forwardRef<PatientFormRef, PatientFormProps>(
<SelectItem value="DeltaIns">Delta Ins</SelectItem> <SelectItem value="DeltaIns">Delta Ins</SelectItem>
<SelectItem value="TuftsSCO">Tufts SCO</SelectItem> <SelectItem value="TuftsSCO">Tufts SCO</SelectItem>
<SelectItem value="UnitedSCO">United SCO</SelectItem> <SelectItem value="UnitedSCO">United SCO</SelectItem>
<SelectItem value="CMSP">CMSP</SelectItem>
<SelectItem value="BCBS">BCBS</SelectItem> <SelectItem value="BCBS">BCBS</SelectItem>
<SelectItem value="UnitedAAPR">United AAPR</SelectItem> <SelectItem value="UnitedAAPR">United AAPR</SelectItem>
<SelectItem value="Aetna">Aetna</SelectItem> <SelectItem value="Aetna">Aetna</SelectItem>
@@ -63,6 +63,7 @@ interface PatientTableProps {
allowCheckbox?: boolean; allowCheckbox?: boolean;
allowNewClaim?: boolean; allowNewClaim?: boolean;
allowFinancial?: boolean; allowFinancial?: boolean;
showEligibilityColumns?: boolean;
onNewClaim?: (patientId: number) => void; onNewClaim?: (patientId: number) => void;
onSelectPatient?: (patient: Patient | null) => void; onSelectPatient?: (patient: Patient | null) => void;
onPageChange?: (page: number) => void; onPageChange?: (page: number) => void;
@@ -82,6 +83,7 @@ export function PatientTable({
allowCheckbox, allowCheckbox,
allowNewClaim, allowNewClaim,
allowFinancial, allowFinancial,
showEligibilityColumns,
onNewClaim, onNewClaim,
onSelectPatient, onSelectPatient,
onPageChange, onPageChange,
@@ -976,6 +978,12 @@ export function PatientTable({
{/*<TableHead>Contact</TableHead>*/} {/*<TableHead>Contact</TableHead>*/}
<TableHead>Insurance</TableHead> <TableHead>Insurance</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
{showEligibilityColumns && (
<>
<TableHead>Remaining</TableHead>
<TableHead>Third Party/2nd Insurance</TableHead>
</>
)}
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -983,7 +991,7 @@ export function PatientTable({
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={showEligibilityColumns ? 8 : 6}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
<LoadingScreen /> <LoadingScreen />
@@ -992,7 +1000,7 @@ export function PatientTable({
) : isError ? ( ) : isError ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={showEligibilityColumns ? 8 : 6}
className="text-center py-8 text-red-500" className="text-center py-8 text-red-500"
> >
Error loading patients. Error loading patients.
@@ -1001,7 +1009,7 @@ export function PatientTable({
) : patientsData?.patients.length === 0 ? ( ) : patientsData?.patients.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={showEligibilityColumns ? 8 : 6}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
No patients found. No patients found.
@@ -1088,8 +1096,28 @@ export function PatientTable({
</span> </span>
)} )}
</div> </div>
{patient.updatedAt && (
<div className="text-xs text-gray-500 mt-1">
Updated: {formatDateToHumanReadable(patient.updatedAt)}
</div>
)}
</TableCell> </TableCell>
{showEligibilityColumns && (
<>
<TableCell>
<div className="text-sm text-gray-900">
{patient.remainingBenefit || "—"}
</div>
</TableCell>
<TableCell>
<div className="text-sm text-gray-900">
{patient.thirdPartyInsurance || "—"}
</div>
</TableCell>
</>
)}
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end"> <div className="flex justify-end">
{allowDelete && isAdmin && ( {allowDelete && isAdmin && (
@@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react"; import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall, Stethoscope } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -20,6 +20,14 @@ type AiChatTemplates = {
generalFallback: string; generalFallback: string;
}; };
type AiCallTemplates = {
greeting: string;
reminderGreeting: string;
newPatientGreeting: string;
generalFallback: string;
recallGreeting: string;
};
type OfficeContact = { type OfficeContact = {
officeName?: string | null; officeName?: string | null;
}; };
@@ -38,6 +46,16 @@ const DEFAULTS = {
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?", "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
}; };
const CALL_DEFAULTS = {
greeting:
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
reminderGreeting: "Sure, I can help with that.",
newPatientGreeting: "No problem, let's get you set up.",
generalFallback: "Sure, how can I help?",
recallGreeting:
"Hi {firstName}, this is Lisa from {officeName}. We wanted to check in — you're due for a routine exam and cleaning. Would you like to make an appointment because your insurance covers it?",
};
const DEFAULT_SMS_TEMPLATES = [ const DEFAULT_SMS_TEMPLATES = [
{ {
id: "default-appt-reminder", id: "default-appt-reminder",
@@ -67,18 +85,19 @@ function LangGraphFlow() {
const nW = 210; const nW = 210;
const nx = cx - nW / 2; // 245 const nx = cx - nW / 2; // 245
// ── Dual entry nodes ────────────────────────────────────────────────────── // ── Triple entry nodes ─────────────────────────────────────────────────────
const entryW = 192; const entryW = 150;
const lEntryCx = 155; const lEntryCx = 130;
const rEntryCx = 545; const mEntryCx = cx; // 350
const e1y = 14, e1h = 56; const rEntryCx = 570;
const e1y = 14, e1h = 70;
// Merge connector (horizontal line where both branches meet) // Merge connector (horizontal line where all three entries meet)
const mergeY = e1y + e1h + 14; // 84 const mergeY = e1y + e1h + 14; // 98
// ── Center sequence ─────────────────────────────────────────────────────── // ── Center sequence ───────────────────────────────────────────────────────
const n2y = mergeY + 14, n2h = 52; // Patient replies y=98 const n2y = mergeY + 14, n2h = 52; // Patient replies
const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164 const n3y = n2y + n2h + 14, n3h = 98; // AI classifies
const forkHY = n3y + n3h + 22; // 270 const forkHY = n3y + n3h + 22; // 270
@@ -88,7 +107,7 @@ function LangGraphFlow() {
// ── YES branch ──────────────────────────────────────────────────────────── // ── YES branch ────────────────────────────────────────────────────────────
const yesW = 195; const yesW = 195;
const yes1y = forkHY + 50, yes1h = 88; // y=320 const yes1y = forkHY + 50, yes1h = 102; // y=320
const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460 const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
// ── NO / Reschedule branch ──────────────────────────────────────────────── // ── NO / Reschedule branch ────────────────────────────────────────────────
@@ -113,7 +132,7 @@ function LangGraphFlow() {
viewBox={`0 0 ${W} ${totalH}`} viewBox={`0 0 ${W} ${totalH}`}
className="w-full max-w-2xl mx-auto" className="w-full max-w-2xl mx-auto"
role="img" role="img"
aria-label="LangGraph Reminder and Reschedule conversation flow" aria-label="LangGraph Reminder, Recall, and Reschedule conversation flow"
> >
<defs> <defs>
<marker id="rg2ah" markerWidth="10" markerHeight="7" <marker id="rg2ah" markerWidth="10" markerHeight="7"
@@ -130,20 +149,29 @@ function LangGraphFlow() {
</marker> </marker>
</defs> </defs>
{/* ══ DUAL ENTRY NODES ════════════════════════════════════════════ */} {/* ══ TRIPLE ENTRY NODES ════════════════════════════════════════════ */}
{/* Left: Reminder SMS */} {/* Left: Reminder SMS */}
<rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} /> <rect x={lEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#EFF6FF" stroke="#3B82F6" strokeWidth={1.5} />
<text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder SMS</text> <text x={lEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">Office sends Reminder</text>
<text x={lEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text> <text x={lEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#1D4ED8">SMS or Call</text>
<text x={lEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#93C5FD">Staff triggers batch send</text>
{/* Middle: Recall call/SMS for an existing patient */}
<rect x={mEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#F5F3FF" stroke="#8B5CF6" strokeWidth={1.5} />
<text x={mEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">Office call or SMS for</text>
<text x={mEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#5B21B6">an existing patient</text>
<text x={mEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#C4B5FD">Recall: free exam &amp; cleaning</text>
{/* Right: Reschedule by Office SMS */} {/* Right: Reschedule by Office SMS */}
<rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} /> <rect x={rEntryCx-entryW/2} y={e1y} width={entryW} height={e1h} rx={8} fill="#FFF7ED" stroke="#F97316" strokeWidth={1.5} />
<text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule SMS</text> <text x={rEntryCx} y={e1y+22} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">Office sends Reschedule</text>
<text x={rEntryCx} y={e1y+40} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text> <text x={rEntryCx} y={e1y+38} textAnchor="middle" fontSize={12} fontWeight="600" fill="#C2410C">SMS</text>
<text x={rEntryCx} y={e1y+56} textAnchor="middle" fontSize={9} fill="#FDBA74">"Reschedule by Office" template</text>
{/* Converging lines → horizontal merge → Patient replies */} {/* Converging lines → horizontal merge → Patient replies */}
<line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={lEntryCx} y1={e1y+e1h} x2={lEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={mEntryCx} y1={e1y+e1h} x2={mEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={rEntryCx} y1={e1y+e1h} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={lEntryCx} y1={mergeY} x2={rEntryCx} y2={mergeY} stroke="#9CA3AF" strokeWidth={1.5} />
<line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" /> <line x1={cx} y1={mergeY} x2={cx} y2={n2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
@@ -158,10 +186,11 @@ function LangGraphFlow() {
{/* N3: AI classifies */} {/* N3: AI classifies */}
<rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} /> <rect x={nx} y={n3y} width={nW} height={n3h} rx={8} fill="#ECFDF5" stroke="#10B981" strokeWidth={1.5} />
<text x={cx} y={n3y+20} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text> <text x={cx} y={n3y+18} textAnchor="middle" fontSize={12} fontWeight="700" fill="#065F46">Google AI classifies YES / NO</text>
<text x={cx} y={n3y+36} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text> <text x={cx} y={n3y+33} textAnchor="middle" fontSize={10} fontWeight="600" fill="#1D4ED8">Reminder: intro (MSG 1) + reply (MSG 2)</text>
<text x={cx} y={n3y+52} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text> <text x={cx} y={n3y+48} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: reply sent directly</text>
<text x={cx} y={n3y+70} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text> <text x={cx} y={n3y+63} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: same as Reschedule flow</text>
<text x={cx} y={n3y+82} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">Date in reply → AI skips to Day Check</text>
{/* Fork lines */} {/* Fork lines */}
<line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} /> <line x1={cx} y1={n3y+n3h} x2={cx} y2={forkHY} stroke="#9CA3AF" strokeWidth={1.5} />
@@ -178,10 +207,11 @@ function LangGraphFlow() {
{/* ══ YES BRANCH ══════════════════════════════════════════════════ */} {/* ══ YES BRANCH ══════════════════════════════════════════════════ */}
<rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} /> <rect x={lcx-yesW/2} y={yes1y} width={yesW} height={yes1h} rx={8} fill="#F0FDF4" stroke="#22C55E" strokeWidth={1.5} />
<text x={lcx} y={yes1y+18} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text> <text x={lcx} y={yes1y+16} textAnchor="middle" fontSize={10} fontWeight="600" fill="#15803D">Reminder: Thank you for confirming!</text>
<text x={lcx} y={yes1y+34} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day &amp; time?</text> <text x={lcx} y={yes1y+31} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day &amp; time?</text>
<text x={lcx} y={yes1y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date &amp; time]"</text> <text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day &amp; time?</text>
<text x={lcx} y={yes1y+70} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text> <text x={lcx} y={yes1y+64} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date &amp; time]"</text>
<text x={lcx} y={yes1y+80} textAnchor="middle" fontSize={8} fill="#047857">date in reply → Day Check ↘</text>
<line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" /> <line x1={lcx} y1={yes1y+yes1h} x2={lcx} y2={yes2y-2} stroke="#9CA3AF" strokeWidth={1.5} markerEnd="url(#rg2ah)" />
{/* Yes2: Patient thanks */} {/* Yes2: Patient thanks */}
@@ -191,7 +221,7 @@ function LangGraphFlow() {
{/* Dashed shortcut: YES with date → Day Check */} {/* Dashed shortcut: YES with date → Day Check */}
<line <line
x1={lcx + yesW/2} y1={yes1y + 52} x1={lcx + yesW/2} y1={yes1y + 64}
x2={rcx - noW/2 - 2} y2={no2y + 34} x2={rcx - noW/2 - 2} y2={no2y + 34}
stroke="#047857" strokeWidth={1} strokeDasharray="4 3" stroke="#047857" strokeWidth={1} strokeDasharray="4 3"
markerEnd="url(#rg2short)" markerEnd="url(#rg2short)"
@@ -1011,6 +1041,13 @@ export function AiChatSettingsCard() {
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
const initialized = useRef(false); const initialized = useRef(false);
const [callGreeting, setCallGreeting] = useState(CALL_DEFAULTS.greeting);
const [callReminderGreeting, setCallReminderGreeting] = useState(CALL_DEFAULTS.reminderGreeting);
const [callNewPatientGreeting, setCallNewPatientGreeting] = useState(CALL_DEFAULTS.newPatientGreeting);
const [callGeneralFallback, setCallGeneralFallback] = useState(CALL_DEFAULTS.generalFallback);
const [callRecallGreeting, setCallRecallGreeting] = useState(CALL_DEFAULTS.recallGreeting);
const callInitialized = useRef(false);
const [openPhoneReply, setOpenPhoneReply] = useState(false); const [openPhoneReply, setOpenPhoneReply] = useState(false);
const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({ const { data: advancedSettings } = useQuery<{ openPhoneReply: boolean }>({
@@ -1090,6 +1127,29 @@ export function AiChatSettingsCard() {
} }
}, [templates]); }, [templates]);
const { data: callTemplatesData, isLoading: callTemplatesLoading } = useQuery<AiCallTemplates>({
queryKey: ["/api/ai/call-templates"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/call-templates");
if (!res.ok) throw new Error("Failed to load call templates");
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
// Seed AI call templates
useEffect(() => {
if (callTemplatesData && !callInitialized.current) {
callInitialized.current = true;
setCallGreeting(callTemplatesData.greeting || CALL_DEFAULTS.greeting);
setCallReminderGreeting(callTemplatesData.reminderGreeting || CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(callTemplatesData.newPatientGreeting || CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(callTemplatesData.generalFallback || CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(callTemplatesData.recallGreeting || CALL_DEFAULTS.recallGreeting);
}
}, [callTemplatesData]);
// Seed SMS template list — fall back to the saved reminderSms if list is empty // Seed SMS template list — fall back to the saved reminderSms if list is empty
useEffect(() => { useEffect(() => {
if (smsTemplateListData && !smsInitialized.current) { if (smsTemplateListData && !smsInitialized.current) {
@@ -1120,6 +1180,24 @@ export function AiChatSettingsCard() {
}, },
}); });
const saveCallMutation = useMutation({
mutationFn: async (data: AiCallTemplates) => {
const res = await apiRequest("PUT", "/api/ai/call-templates", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save call templates");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/call-templates"] });
toast({ title: "Templates saved", description: "AI call templates have been updated." });
},
onError: (err: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
const saveSmsListMutation = useMutation({ const saveSmsListMutation = useMutation({
mutationFn: async (list: SmsTemplate[]) => { mutationFn: async (list: SmsTemplate[]) => {
const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list); const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list);
@@ -1147,6 +1225,17 @@ export function AiChatSettingsCard() {
}); });
}; };
const handleCallSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCallMutation.mutate({
greeting: callGreeting.trim() || CALL_DEFAULTS.greeting,
reminderGreeting: callReminderGreeting.trim() || CALL_DEFAULTS.reminderGreeting,
newPatientGreeting: callNewPatientGreeting.trim() || CALL_DEFAULTS.newPatientGreeting,
generalFallback: callGeneralFallback.trim() || CALL_DEFAULTS.generalFallback,
recallGreeting: callRecallGreeting.trim() || CALL_DEFAULTS.recallGreeting,
});
};
const updateSmsTemplate = (idx: number, field: "name" | "body", value: string) => { const updateSmsTemplate = (idx: number, field: "name" | "body", value: string) => {
setSmsTemplates((prev) => prev.map((t, i) => i === idx ? { ...t, [field]: value } : t)); setSmsTemplates((prev) => prev.map((t, i) => i === idx ? { ...t, [field]: value } : t));
}; };
@@ -1193,6 +1282,54 @@ export function AiChatSettingsCard() {
}, },
]; ];
const callTemplateFields = [
{
key: "callGreeting",
icon: <PhoneCall className="h-4 w-4 text-primary" />,
label: "Call an existing patient for an appointment",
description: "Lisa's opening line when placing an AI Call to a patient — use {firstName} and {officeName} as placeholders.",
value: callGreeting,
onChange: setCallGreeting,
placeholder: CALL_DEFAULTS.greeting,
},
{
key: "callRecall",
icon: <Stethoscope className="h-4 w-4 text-primary" />,
label: "Recall Call: Existing Patient Check-Up Offer",
description: "Lisa's opening line for a Patient Connection \"AI Call\" — offers an existing patient a routine exam & cleaning (most insurance covers it at no cost) and asks if they'd like to book. Use {firstName} and {officeName} as placeholders.",
value: callRecallGreeting,
onChange: setCallRecallGreeting,
placeholder: CALL_DEFAULTS.recallGreeting,
},
{
key: "callReminder",
icon: <CalendarCheck className="h-4 w-4 text-primary" />,
label: "Reminder Call Reply",
description: "Spoken after the call's opening greeting when the AI is following up on an appointment reminder — keep it short, it's read aloud, not texted.",
value: callReminderGreeting,
onChange: setCallReminderGreeting,
placeholder: CALL_DEFAULTS.reminderGreeting,
},
{
key: "callNewPatient",
icon: <UserPlus className="h-4 w-4 text-primary" />,
label: "New Patient Call Reply",
description: "Spoken when the AI is walking a new patient through scheduling on a live call.",
value: callNewPatientGreeting,
onChange: setCallNewPatientGreeting,
placeholder: CALL_DEFAULTS.newPatientGreeting,
},
{
key: "callGeneral",
icon: <MessageCircle className="h-4 w-4 text-primary" />,
label: "General Call Fallback",
description: "Spoken when the AI cannot determine the context of what the patient said on a call.",
value: callGeneralFallback,
onChange: setCallGeneralFallback,
placeholder: CALL_DEFAULTS.generalFallback,
},
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -1403,6 +1540,86 @@ export function AiChatSettingsCard() {
</CardContent> </CardContent>
</Card> </Card>
{/* ── Section 1b: Call Templates ───────────────────────────── */}
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<PhoneCall className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Call Templates</h3>
</div>
<p className="text-sm text-muted-foreground">
Customize what Lisa says on live AI phone calls — kept separate from the SMS wording
above since spoken conversation reads differently than a text message. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{officeName}"}</code>{" "}
and{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">{"{firstName}"}</code>{" "}
as placeholders.
</p>
{officeName && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
<span>
<span className="font-medium">{"{officeName}"}</span> will display as{" "}
<span className="font-medium text-foreground">"{officeName}"</span>
</span>
</div>
)}
{callTemplatesLoading ? (
<p className="text-sm text-muted-foreground">Loading templates...</p>
) : (
<form onSubmit={handleCallSubmit} className="space-y-5">
{callTemplateFields.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center gap-2">
{f.icon}
<span className="text-sm font-medium">{f.label}</span>
</div>
<p className="text-xs text-muted-foreground">{f.description}</p>
<Textarea
value={f.value}
onChange={(e) => f.onChange(e.target.value)}
placeholder={f.placeholder}
rows={3}
className="text-sm resize-none"
/>
{officeName && f.value.includes("{officeName}") && (
<p className="text-xs text-muted-foreground italic pl-1">
Preview: {previewTemplate(f.value, officeName)}
</p>
)}
</div>
))}
<div className="flex items-center gap-3 pt-1">
<Button
type="submit"
disabled={saveCallMutation.isPending}
className="bg-teal-600 hover:bg-teal-700 text-white"
>
{saveCallMutation.isPending ? "Saving..." : "Save Templates"}
</Button>
<Button
type="button"
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => {
setCallGreeting(CALL_DEFAULTS.greeting);
setCallReminderGreeting(CALL_DEFAULTS.reminderGreeting);
setCallNewPatientGreeting(CALL_DEFAULTS.newPatientGreeting);
setCallGeneralFallback(CALL_DEFAULTS.generalFallback);
setCallRecallGreeting(CALL_DEFAULTS.recallGreeting);
}}
>
Reset to defaults
</Button>
</div>
</form>
)}
</CardContent>
</Card>
{/* ── Section 2: LangGraph Flow ────────────────────────────── */} {/* ── Section 2: LangGraph Flow ────────────────────────────── */}
<Card> <Card>
<CardContent className="py-6 space-y-5"> <CardContent className="py-6 space-y-5">
@@ -0,0 +1,69 @@
import { useRef, useState } from "react";
// Minimal single-shot screen capture, extracted from the batching/cropping logic in
// ai-copy-agent-page.tsx — this just needs "take a screenshot, attach it."
export function useScreenCapture() {
const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const stopStream = () => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
// Resolves with a PNG data URL of the full screenshot (caller crops before converting to a File).
async function capture(): Promise<string> {
setIsCapturing(true);
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "monitor" },
});
streamRef.current = stream;
const video = document.createElement("video");
video.srcObject = stream;
video.muted = true;
await video.play();
await new Promise<void>((resolve) => {
if (video.readyState >= 2) resolve();
else video.onloadeddata = () => resolve();
});
// Short countdown so the user has time to alt-tab to the window they want to capture.
for (let secondsLeft = 2; secondsLeft > 0; secondsLeft--) {
setCountdown(secondsLeft);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
setCountdown(null);
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0, canvas.width, canvas.height);
stopStream();
return canvas.toDataURL("image/png");
} catch (error) {
stopStream();
const name = error instanceof Error ? error.name : "";
const message =
name === "NotAllowedError"
? "Screen share permission was denied or the picker was cancelled."
: name === "NotFoundError"
? "No screen/window source was available to capture."
: !navigator.mediaDevices?.getDisplayMedia
? "Screen capture isn't supported in this browser."
: error instanceof Error
? error.message
: "Screen capture failed.";
throw new Error(message);
} finally {
setIsCapturing(false);
setCountdown(null);
}
}
return { capture, isCapturing, countdown };
}
+359 -43
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop"; import ReactCrop, { Crop, PixelCrop, centerCrop, makeAspectCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css"; import "react-image-crop/dist/ReactCrop.css";
import { Camera, Copy, RotateCcw, Save } from "lucide-react"; import { Camera, Copy, Plus, RotateCcw, Save, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -33,6 +33,20 @@ function getCroppedDataUrl(image: HTMLImageElement, crop: PixelCrop): string {
return canvas.toDataURL("image/png"); return canvas.toDataURL("image/png");
} }
interface BatchedScreenshot {
id: string;
dataUrl: string;
fileName: string;
}
interface TempPatient {
id: number;
firstName: string | null;
lastName: string | null;
}
const PENDING_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function AiCopyAgentPage() { export default function AiCopyAgentPage() {
const { toast } = useToast(); const { toast } = useToast();
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null); const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
@@ -43,6 +57,8 @@ export default function AiCopyAgentPage() {
const [isCapturing, setIsCapturing] = useState(false); const [isCapturing, setIsCapturing] = useState(false);
const [countdown, setCountdown] = useState<number | null>(null); const [countdown, setCountdown] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [batch, setBatch] = useState<BatchedScreenshot[]>([]);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
@@ -132,47 +148,97 @@ export default function AiCopyAgentPage() {
handleCapture(); handleCapture();
}; };
const clearActive = () => {
setScreenshot(null);
setFileName("");
setCrop(undefined);
setCompletedCrop(undefined);
};
// Applies the current crop to the active screenshot and returns it, without touching state.
const finalizeActive = (): BatchedScreenshot | null => {
if (!screenshot) return null;
const croppedImage =
completedCrop && imgRef.current
? getCroppedDataUrl(imgRef.current, completedCrop)
: screenshot;
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
return { id: crypto.randomUUID(), dataUrl: croppedImage, fileName: safeName };
};
const handleAddAnother = async () => {
const item = finalizeActive();
if (!item) return;
setBatch((prev) => [...prev, item]);
clearActive();
await handleCapture();
};
const handleRemoveBatchItem = (id: string) => {
setBatch((prev) => prev.filter((item) => item.id !== id));
};
const getPatientName = (patient: Patient) =>
patient.firstName && patient.lastName
? `${patient.firstName} ${patient.lastName}`
: patient.firstName ?? `patient-${patient.id}`;
const itemsToFiles = async (items: BatchedScreenshot[]): Promise<File[]> => {
const files: File[] = [];
for (const item of items) {
const response = await fetch(item.dataUrl);
const blob = await response.blob();
files.push(new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
return files;
};
const uploadToPatient = async (
patientId: number | undefined,
patientName: string,
files: File[],
pending: boolean
) => {
const formData = new FormData();
formData.append("patientId", String(patientId));
formData.append("patientName", patientName);
if (pending) formData.append("pending", "true");
// Lets the backend keep a local-disk-only debug copy — not shown anywhere in the UI.
formData.append("screenshotBackup", "true");
for (const file of files) formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
};
const getPendingItems = (): BatchedScreenshot[] => {
const items = [...batch];
const active = finalizeActive();
if (active) items.push(active);
return items;
};
const handleSave = async () => { const handleSave = async () => {
if (!screenshot || !selectedPatient) return; if (!selectedPatient) return;
const items = getPendingItems();
if (!items.length) return;
setIsSaving(true); setIsSaving(true);
try { try {
const croppedImage = const patientName = getPatientName(selectedPatient);
completedCrop && imgRef.current const files = await itemsToFiles(items);
? getCroppedDataUrl(imgRef.current, completedCrop) await uploadToPatient(selectedPatient.id, patientName, files, false);
: screenshot;
const response = await fetch(croppedImage);
const blob = await response.blob();
const safeName =
fileName.trim().replace(/[/\\?%*:|"<>]/g, "-") || `screenshot_${Date.now()}`;
const file = new File([blob], `${safeName}.png`, { type: "image/png" });
const patientName =
selectedPatient.firstName && selectedPatient.lastName
? `${selectedPatient.firstName} ${selectedPatient.lastName}`
: selectedPatient.firstName ?? `patient-${selectedPatient.id}`;
const formData = new FormData();
formData.append("patientId", String(selectedPatient.id));
formData.append("patientName", patientName);
formData.append("files", file);
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
toast({ toast({
title: "Saved", title: "Saved",
description: `Screenshot saved to ${patientName}'s attachments.`, description: `${items.length} screenshot${items.length > 1 ? "s" : ""} saved to ${patientName}'s attachments.`,
}); });
setScreenshot(null); setBatch([]);
setFileName(""); clearActive();
setCrop(undefined);
setCompletedCrop(undefined);
} catch (error) { } catch (error) {
console.error("Error saving screenshot:", error); console.error("Error saving screenshot:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to save screenshot to patient attachments.", description: "Failed to save screenshots to patient attachments.",
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@@ -180,6 +246,194 @@ export default function AiCopyAgentPage() {
} }
}; };
const detectFromFiles = async (files: File[]) => {
const detectFormData = new FormData();
for (const file of files) detectFormData.append("files", file);
const response = await apiRequest("POST", "/api/ai/detect-eligibility-info", detectFormData);
return response.json();
};
const handleAiDetectAndCheck = async () => {
const items = getPendingItems();
if (!items.length) return;
if (selectedPatient) {
await runAiDetectForSelectedPatient(selectedPatient, items);
} else {
await runAiDetectWithoutPatient(items);
}
};
const runAiDetectForSelectedPatient = async (patient: Patient, items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const patientName = getPatientName(patient);
const files = await itemsToFiles(items);
const [uploadResult, detectResult] = await Promise.allSettled([
uploadToPatient(patient.id, patientName, files, true),
detectFromFiles(files),
]);
const saved = uploadResult.status === "fulfilled";
if (saved) {
setBatch([]);
clearActive();
}
if (detectResult.status === "rejected" || detectResult.value?.error) {
toast({
title: saved ? "Saved, but AI detection failed" : "Error",
description: saved
? "Screenshots saved. Enter the Member ID/DOB manually in the chatbot to check eligibility."
: "Failed to save screenshots and detect Member ID/DOB.",
variant: saved ? undefined : "destructive",
});
return;
}
if (!saved) {
toast({
title: "Detected, but save failed",
description: "Read the Member ID/DOB, but failed to save the screenshots. Try Save All again.",
variant: "destructive",
});
}
const memberId: string | null = detectResult.value?.data?.memberId ?? null;
const dob: string | null = detectResult.value?.data?.dob ?? null;
const autoCheck: string = detectResult.value?.data?.autoCheck ?? "mh";
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck,
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: patient.insuranceId ?? null,
insuranceProvider: null,
dateOfBirth: patient.dateOfBirth ?? null,
},
patientId: patient.id,
pendingFolderPatientName: patientName,
},
})
);
} finally {
setIsDetecting(false);
}
};
// No patient selected: detect first (need memberId+dob to create a patient at all), then
// either auto-create a temp "PID-<id>" patient and save under it, or — if detection came back
// incomplete — leave the screenshots unsaved and let the user complete the details in the
// chatbot manually; insurance-status-page.tsx picks the pending screenshots back up once that
// manual check resolves to a real patient.
const runAiDetectWithoutPatient = async (items: BatchedScreenshot[]) => {
setIsDetecting(true);
try {
const files = await itemsToFiles(items);
let detected: { memberId: string | null; dob: string | null; autoCheck: string };
try {
const json = await detectFromFiles(files);
if (json?.error) throw new Error(json.message || "Detection failed");
detected = { memberId: json?.data?.memberId ?? null, dob: json?.data?.dob ?? null, autoCheck: json?.data?.autoCheck ?? "mh" };
} catch (error) {
console.error("Error detecting Member ID/DOB:", error);
toast({
title: "Error",
description: "Failed to read the screenshots. Please try again.",
variant: "destructive",
});
return;
}
const { memberId, dob, autoCheck } = detected;
if (memberId && dob) {
let tempPatient: TempPatient;
try {
const res = await apiRequest("POST", "/api/patients/create-temp", { memberId, dob });
const json = await res.json();
tempPatient = json.data;
} catch (error) {
console.error("Error creating temp patient:", error);
toast({
title: "Error",
description: "Failed to create a patient record for this screenshot.",
variant: "destructive",
});
return;
}
const patientName = getPatientName(tempPatient as Patient);
try {
await uploadToPatient(tempPatient.id, patientName, files, false);
} catch (error) {
console.error("Error saving screenshots to temp patient:", error);
toast({
title: "Detected, but save failed",
description: `Created ${patientName}, but failed to save the screenshots. Select them from the patient list and try Save All.`,
variant: "destructive",
});
return;
}
setBatch([]);
clearActive();
setSelectedPatient(tempPatient as Patient);
toast({
title: "Patient created",
description: `Saved as ${patientName}. Its name will update automatically once the eligibility check confirms it.`,
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: {
memberId,
dob,
autoCheck,
patient: {
id: tempPatient.id,
firstName: tempPatient.firstName,
lastName: tempPatient.lastName,
insuranceId: memberId,
insuranceProvider: null,
dateOfBirth: dob,
},
patientId: tempPatient.id,
},
})
);
} else {
try {
sessionStorage.setItem(
PENDING_SCREENSHOTS_KEY,
JSON.stringify(items.map((item) => ({ dataUrl: item.dataUrl, fileName: item.fileName })))
);
} catch {}
toast({
title: "Couldn't read Member ID/DOB",
description:
"Complete the details in the chatbot — your screenshots will attach automatically once that check finishes.",
});
window.dispatchEvent(
new CustomEvent("chatbot:open-eligibility-id-ready", {
detail: { memberId, dob, autoCheck, patient: null },
})
);
}
} finally {
setIsDetecting(false);
}
};
return ( return (
<div className="container mx-auto space-y-6"> <div className="container mx-auto space-y-6">
@@ -200,10 +454,34 @@ export default function AiCopyAgentPage() {
<CardHeader> <CardHeader>
<CardTitle>Capture Screenshot</CardTitle> <CardTitle>Capture Screenshot</CardTitle>
<CardDescription> <CardDescription>
Capture your screen and save it to a patient's attachments. Capture your screen and save it to a patient's attachments. Take multiple
screenshots and save them together as one batch.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{batch.length > 0 && (
<div className="flex flex-wrap gap-2">
{batch.map((item) => (
<div key={item.id} className="relative">
<img
src={item.dataUrl}
alt={item.fileName}
className="h-16 w-16 rounded-md border object-cover"
/>
<button
type="button"
onClick={() => handleRemoveBatchItem(item.id)}
disabled={isSaving}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border shadow-sm p-0.5"
title="Remove from batch"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{screenshot ? ( {screenshot ? (
<div className="space-y-4"> <div className="space-y-4">
<ReactCrop <ReactCrop
@@ -238,30 +516,67 @@ export default function AiCopyAgentPage() {
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Retake Retake
</Button> </Button>
<Button variant="outline" onClick={handleAddAnother} disabled={isCapturing || isSaving}>
<Plus className="h-4 w-4 mr-2" />
Add Another
</Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={!selectedPatient || isSaving} disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined} title={!selectedPatient ? "Select a patient below first" : undefined}
> >
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : "Save to Patient"} {isSaving ? "Saving..." : `Save All${batch.length ? ` (${batch.length + 1})` : ""}`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button> </Button>
</div> </div>
{!selectedPatient && ( {!selectedPatient && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Select a patient below to enable saving. Select a patient below to enable Save All, or use "AI Detect &amp; Check
Eligibility" without selecting one to create a patient automatically.
</p> </p>
)} )}
</div> </div>
) : ( ) : (
<Button onClick={handleCapture} disabled={isCapturing}> <div className="flex flex-wrap gap-3">
<Camera className="h-4 w-4 mr-2" /> <Button onClick={handleCapture} disabled={isCapturing}>
{countdown !== null <Camera className="h-4 w-4 mr-2" />
? `Capturing in ${countdown}...` {countdown !== null
: isCapturing ? `Capturing in ${countdown}...`
? "Waiting for screen share..." : isCapturing
: "Take Screenshot"} ? "Waiting for screen share..."
</Button> : batch.length > 0
? "Take Another Screenshot"
: "Take Screenshot"}
</Button>
{batch.length > 0 && (
<>
<Button
onClick={handleSave}
disabled={!selectedPatient || isSaving || isDetecting}
title={!selectedPatient ? "Select a patient below first" : undefined}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? "Saving..." : `Save All (${batch.length})`}
</Button>
<Button
variant="secondary"
onClick={handleAiDetectAndCheck}
disabled={isSaving || isDetecting}
>
<Sparkles className="h-4 w-4 mr-2" />
{isDetecting ? "Detecting..." : "AI Detect & Check Eligibility"}
</Button>
</>
)}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -271,7 +586,8 @@ export default function AiCopyAgentPage() {
<CardHeader> <CardHeader>
<CardTitle>Patient Records</CardTitle> <CardTitle>Patient Records</CardTitle>
<CardDescription> <CardDescription>
Select the patient this screenshot belongs to. Select the patient this screenshot belongs to, or leave unselected and use
"AI Detect &amp; Check Eligibility" to create one automatically.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+383 -101
View File
@@ -1,37 +1,214 @@
import { Keyboard, FileCheck, CreditCard, Shield, Zap, ArrowRight } from "lucide-react"; import { ReactNode, useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Keyboard, CalendarPlus, UserPlus, CreditCard, Circle, CheckCircle2, Monitor, X, Loader2, XCircle, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { PatientTable } from "@/components/patients/patient-table";
import { Patient } from "@repo/db/types";
import { apiRequest } from "@/lib/queryClient";
import { socket } from "@/lib/socket";
const SOFTWARE_TARGETS = [ type SoftwareId = "open-dental" | "dentrix" | "eaglesoft";
{ name: "Open Dental", color: "bg-blue-100 text-blue-700 border-blue-200" }, type ActionId = "existing-patient" | "new-patient" | "payment";
{ name: "Eaglesoft", color: "bg-emerald-100 text-emerald-700 border-emerald-200" },
{ name: "Dentrix", color: "bg-violet-100 text-violet-700 border-violet-200" }, interface ConnectedAgent {
{ name: "Curve Dental", color: "bg-orange-100 text-orange-700 border-orange-200" }, socketId: string;
ip: string;
connectedAt: string;
}
interface LiveStep {
index: number;
total: number;
label: string;
status: "running" | "done" | "error";
error?: string;
}
const SOFTWARE_OPTIONS: { id: SoftwareId; name: string }[] = [
{ id: "open-dental", name: "Open Dental" },
{ id: "dentrix", name: "Dentrix" },
{ id: "eaglesoft", name: "Eaglesoft" },
]; ];
const CAPABILITIES = [ const ACTIONS: { id: ActionId; icon: ReactNode; label: string }[] = [
{ { id: "existing-patient", icon: <CalendarPlus className="h-4 w-4" />, label: "Make an appointment for an existing patient" },
icon: <Shield className="h-5 w-5 text-teal-600" />, { id: "new-patient", icon: <UserPlus className="h-4 w-4" />, label: "Make an appointment for a new patient" },
title: "Eligibility Results", { id: "payment", icon: <CreditCard className="h-4 w-4" />, label: "Type payments" },
description:
"Automatically type insurance eligibility information — coverage status, plan details, deductibles, and co-pays — directly into the patient chart in your dental software.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
title: "Claim Information",
description:
"Transfer claim numbers, claim status updates, and denial reasons from the insurance portal results into your claims module without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
title: "Insurance Payments",
description:
"Post ERA / EOB payment details — amounts, adjustments, patient responsibility — directly into your payment ledger by typing them into the active software window.",
},
]; ];
// Preview-only step sequences. Same shape across the three software targets for now —
// each software's real step semantics (field positions, screen titles) will differ once
// the Windows Agent drives them via vision, but the flow order is the same.
const STEP_TEMPLATES: Record<ActionId, (softwareName: string, patientName: string) => string[]> = {
"existing-patient": (sw, patient) => [
`Double-click to open the patient selection window in ${sw}`,
`Screenshot the window and locate the Last Name field`,
`Type "${patient}"'s last name into the field`,
`Locate and click the Search button`,
`Locate and click the matching patient row`,
`Screenshot the Add Procedure window and locate the default procedure (Exam)`,
`Click the procedure, then click Create Appointment`,
],
"new-patient": (sw, patient) => [
`Open the New Patient entry screen in ${sw}`,
`Screenshot the form and locate the First/Last Name fields`,
`Type "${patient}"'s name, date of birth, and insurance into the form`,
`Locate and click Save / Create Patient`,
`Screenshot the Add Procedure window and locate the default procedure (Exam)`,
`Click the procedure, then click Create Appointment`,
],
payment: (sw, patient) => [
`Locate "${patient}" in ${sw} and open their ledger / payment screen`,
`Screenshot the screen and locate the payment amount field`,
`Type the payment amount, date, and type`,
`Locate and click Save / Post Payment`,
],
};
export default function AiTypeAgentPage() { export default function AiTypeAgentPage() {
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [software, setSoftware] = useState<SoftwareId | "">("open-dental");
const [runAction, setRunAction] = useState<ActionId | null>(null);
const [agents, setAgents] = useState<ConnectedAgent[]>([]);
const [selectedAgentIp, setSelectedAgentIp] = useState<string | null>(null);
// Temporary debug toggle: skips vision lookup for the patient-row click and moves to a
// hardcoded ratio instead, to test move/double-click mechanics in isolation. Remove once
// the vision-based row click is confirmed reliable.
const [ratioTest, setRatioTest] = useState(false);
useEffect(() => {
apiRequest("GET", "/api/type-agent/status")
.then((res) => res.json())
.then((data) => setAgents(Array.isArray(data?.agents) ? data.agents : []))
.catch(() => {});
const handleStatus = (data: { agents: ConnectedAgent[] }) =>
setAgents(Array.isArray(data?.agents) ? data.agents : []);
socket.on("type-agent:status", handleStatus);
return () => {
socket.off("type-agent:status", handleStatus);
};
}, []);
// Keep the selected PC valid as the connected list changes — auto-pick when there's
// exactly one, and drop the selection if its PC disconnects.
useEffect(() => {
if (selectedAgentIp && !agents.some((a) => a.ip === selectedAgentIp)) {
setSelectedAgentIp(null);
} else if (!selectedAgentIp && agents.length === 1) {
setSelectedAgentIp(agents[0]!.ip);
}
}, [agents, selectedAgentIp]);
const [liveSteps, setLiveSteps] = useState<LiveStep[]>([]);
const [runStartError, setRunStartError] = useState<string | null>(null);
const [startCountdown, setStartCountdown] = useState<number | null>(null);
// Open Dental / existing-patient is the only combination actually wired to the real
// Windows Agent so far — everything else still shows the static preview below.
const isAutomated = software === "open-dental" && runAction === "existing-patient";
const START_DELAY_SECONDS = 5;
useEffect(() => {
if (!isAutomated || !runAction || !selectedAgentIp || !selectedPatient) return;
let cancelled = false;
let liveRunId: string | null = null;
const handleProgress = (data: LiveStep) => {
setLiveSteps((prev) => {
const next = [...prev];
next[data.index] = data;
return next;
});
};
setLiveSteps([]);
setRunStartError(null);
setStartCountdown(START_DELAY_SECONDS);
// Gives time to minimize/switch away from Chrome and let the target software have
// focus before the first (mouse-position-dependent) step fires.
const countdownInterval = setInterval(() => {
setStartCountdown((c) => (c && c > 1 ? c - 1 : 0));
}, 1000);
const startTimeout = setTimeout(() => {
clearInterval(countdownInterval);
setStartCountdown(null);
apiRequest("POST", "/api/type-agent/run", {
software,
action: runAction,
ip: selectedAgentIp,
patientId: selectedPatient.id,
ratioTest,
})
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
if (data?.error) {
setRunStartError(data.error);
return;
}
liveRunId = data.runId;
socket.on(`type-agent:run:${data.runId}`, handleProgress);
})
.catch((err) => {
if (!cancelled) setRunStartError(err instanceof Error ? err.message : "Failed to start run");
});
}, START_DELAY_SECONDS * 1000);
return () => {
cancelled = true;
clearInterval(countdownInterval);
clearTimeout(startTimeout);
if (liveRunId) socket.off(`type-agent:run:${liveRunId}`, handleProgress);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAutomated, runAction, selectedAgentIp, selectedPatient?.id, ratioTest]);
const getPatientName = (patient: Patient) =>
patient.firstName && patient.lastName
? `${patient.firstName} ${patient.lastName}`
: patient.firstName ?? `patient-${patient.id}`;
const softwareName = SOFTWARE_OPTIONS.find((s) => s.id === software)?.name ?? "";
const handleDisconnect = async (ip: string) => {
try {
await apiRequest("POST", "/api/type-agent/disconnect", { ip });
} catch (error) {
console.error("Error disconnecting agent:", error);
}
};
const agentConnected = agents.length > 0;
const canRun = Boolean(software && selectedPatient && selectedAgentIp);
const activeAction = ACTIONS.find((a) => a.id === runAction);
const steps =
runAction && selectedPatient
? STEP_TEMPLATES[runAction](softwareName, getPatientName(selectedPatient))
: [];
return ( return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10"> <div className="container mx-auto space-y-6">
{/* Hero */} {/* Hero */}
<div className="text-center space-y-3"> <div className="text-center space-y-3">
@@ -48,89 +225,194 @@ export default function AiTypeAgentPage() {
</span> </span>
</div> </div>
{/* How it works */} {/* Run setup */}
<Card> <Card>
<CardContent className="py-6 space-y-4"> <CardHeader>
<div className="flex items-center gap-2"> <CardTitle>Run Setup</CardTitle>
<Keyboard className="h-5 w-5 text-violet-500" /> <CardDescription>
<h2 className="text-base font-semibold">How it works</h2> Choose your dental software, the PC to control, and a patient below, then run one
of the actions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<div className="w-56">
<Select value={software} onValueChange={(v) => setSoftware(v as SoftwareId)}>
<SelectTrigger>
<SelectValue placeholder="Choose software..." />
</SelectTrigger>
<SelectContent>
{SOFTWARE_OPTIONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{agentConnected ? (
<Badge variant="outline" className="gap-1.5 text-muted-foreground border-emerald-200 bg-emerald-50">
<Circle className="h-2 w-2 fill-emerald-500 text-emerald-500" />
{agents.length} PC{agents.length > 1 ? "s" : ""} Connected
</Badge>
) : (
<Badge variant="outline" className="gap-1.5 text-muted-foreground border-red-200 bg-red-50">
<Circle className="h-2 w-2 fill-red-500 text-red-500" />
No PC Connected
</Badge>
)}
{selectedPatient ? (
<Badge variant="secondary">Patient: {getPatientName(selectedPatient)}</Badge>
) : (
<span className="text-xs text-muted-foreground">Select a patient below</span>
)}
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm">
{[ {/* Temporary debug toggle — remove once the vision-based patient-row click is
"Retrieve data in this app\n(eligibility, claim, payment)", confirmed reliable */}
"Agent identifies the active\nfield in your dental software", <label className="flex items-center gap-2 text-xs text-muted-foreground">
"Types the value directly\nvia keyboard automation", <Checkbox checked={ratioTest} onCheckedChange={(v) => setRatioTest(v === true)} />
].map((step, i) => ( Ratio test: skip AI lookup for the patient row, move to fixed ratio (0.31, 0.21) instead
<div key={i} className="flex items-center gap-3 flex-1"> </label>
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-violet-100 text-violet-700 text-xs font-bold flex items-center justify-center">
{i + 1} {/* Connected PCs */}
</div> <div className="space-y-1.5">
<p className="whitespace-pre-line text-muted-foreground leading-snug">{step}</p> <p className="text-xs font-medium text-muted-foreground">Windows PC to control</p>
{i < 2 && <ArrowRight className="h-4 w-4 text-muted-foreground/40 hidden sm:block flex-shrink-0" />} {agents.length === 0 ? (
<p className="text-xs text-muted-foreground">
No DentalAgent.exe is connected yet. Run it on the front-desk PC you want to
control — it will show up here.
</p>
) : (
<div className="flex flex-wrap gap-2">
{agents.map((agent) => (
<div
key={agent.socketId}
className={`flex items-center gap-1.5 rounded-lg border pl-3 pr-1.5 py-1.5 text-sm transition-colors ${
selectedAgentIp === agent.ip
? "border-violet-300 bg-violet-50 text-violet-700"
: "border-input hover:bg-accent"
}`}
>
<button
type="button"
onClick={() => setSelectedAgentIp(agent.ip)}
className="flex items-center gap-1.5"
>
<Monitor className="h-3.5 w-3.5" />
{agent.ip}
</button>
<button
type="button"
onClick={() => handleDisconnect(agent.ip)}
title="Disconnect this PC"
className="rounded-full p-0.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div> </div>
)}
</div>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Maximize2 className="h-3.5 w-3.5 flex-shrink-0" />
Maximize {softwareName || "your dental software"} to full screen on the target PC before running an action — the agent reads the whole screen, so a small or floating window makes it harder to find things reliably.
</p>
<div className="grid gap-3 sm:grid-cols-3">
{ACTIONS.map((action) => (
<Button
key={action.id}
variant="outline"
disabled={!canRun}
title={!canRun ? "Choose software, a PC, and a patient first" : undefined}
onClick={() => setRunAction(action.id)}
className="h-auto py-4 justify-start gap-2 whitespace-normal text-left"
>
{action.icon}
{action.label}
</Button>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground pt-1 border-t">
The agent runs as a lightweight local process on your workstation. It receives
instructions from this app and uses keyboard automation to type data into whatever
window is currently focused in your dental software — no clipboard involved.
</p>
</CardContent> </CardContent>
</Card> </Card>
{/* What it will type */} {/* Patient search */}
<div className="space-y-3"> <Card>
<h2 className="text-base font-semibold">What it will type</h2> <CardHeader>
<div className="grid gap-4 sm:grid-cols-3"> <CardTitle>Patient Records</CardTitle>
{CAPABILITIES.map((cap) => ( <CardDescription>
<Card key={cap.title}> Select the patient to use for the action above.
<CardContent className="py-5 space-y-2"> </CardDescription>
<div className="flex items-center gap-2"> </CardHeader>
{cap.icon} <CardContent>
<span className="text-sm font-medium">{cap.title}</span> <PatientTable allowCheckbox={true} onSelectPatient={setSelectedPatient} />
</div> </CardContent>
<p className="text-xs text-muted-foreground leading-relaxed"> </Card>
{cap.description}
{/* Run panel */}
<Dialog open={runAction !== null} onOpenChange={(open) => !open && setRunAction(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{activeAction?.label} — {softwareName}
</DialogTitle>
<DialogDescription>
{isAutomated
? `Running live on the connected PC at ${selectedAgentIp}.`
: selectedAgentIp
? `Running on the connected PC at ${selectedAgentIp}.`
: "Preview only — no PC selected, so these steps won't run automatically."}
</DialogDescription>
</DialogHeader>
{isAutomated ? (
<div className="space-y-2">
{runStartError && (
<p className="text-sm text-destructive">{runStartError}</p>
)}
{startCountdown !== null && (
<p className="text-sm font-medium text-violet-700">
Starting in {startCountdown}s — minimize or switch away from this window now.
</p> </p>
</CardContent> )}
</Card> {!runStartError && startCountdown === null && liveSteps.length === 0 && (
))} <p className="text-sm text-muted-foreground">Starting run...</p>
</div> )}
</div> {liveSteps.map((step, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
{/* Supported software */} <div className="flex-shrink-0 mt-0.5">
<div className="space-y-3"> {step.status === "running" && <Loader2 className="h-4 w-4 animate-spin text-violet-500" />}
<h2 className="text-base font-semibold">Planned software support</h2> {step.status === "done" && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
<div className="flex flex-wrap gap-2"> {step.status === "error" && <XCircle className="h-4 w-4 text-destructive" />}
{SOFTWARE_TARGETS.map((s) => ( </div>
<span <div>
key={s.name} <p className="leading-snug pt-0.5">{step.label}</p>
className={`text-sm font-medium px-3 py-1.5 rounded-lg border ${s.color}`} {step.error && <p className="text-xs text-destructive mt-0.5">{step.error}</p>}
> </div>
{s.name} </div>
</span> ))}
))} </div>
</div> ) : (
<p className="text-xs text-muted-foreground"> <div className="space-y-2">
Each software has its own field layout. The agent will include pre-built templates {steps.map((step, i) => (
for common workflows in each platform. <div key={i} className="flex items-start gap-3 text-sm">
</p> <div className="flex-shrink-0 mt-0.5">
</div> <span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{i + 1}
{/* Why */} </span>
<Card className="border-violet-200 bg-violet-50/50"> </div>
<CardContent className="py-5 flex items-start gap-3"> <p className="text-muted-foreground leading-snug pt-0.5">{step}</p>
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" /> </div>
<div className="space-y-1"> ))}
<p className="text-sm font-medium text-violet-900">Why this matters</p> </div>
<p className="text-sm text-violet-700 leading-relaxed"> )}
Staff currently check eligibility or look up a claim here, then manually retype </DialogContent>
every value into Open Dental or Eaglesoft. This agent eliminates that step entirely — </Dialog>
one click sends the data straight into the right field.
</p>
</div>
</CardContent>
</Card>
</div> </div>
); );
@@ -76,6 +76,7 @@ import { PatientStatusBadge } from "@/components/appointments/patient-status-bad
import type { OfficeHoursData } from "@/components/settings/office-hours-card"; import type { OfficeHoursData } from "@/components/settings/office-hours-card";
import { MessageThread } from "@/components/patient-connection/message-thread"; import { MessageThread } from "@/components/patient-connection/message-thread";
import { getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils"; import { getAppointmentTypeLabel } from "@/utils/appointmentTypeUtils";
import { getDescriptionForCode } from "@/utils/procedureCombosMapping";
// Define types for scheduling // Define types for scheduling
interface TimeSlot { interface TimeSlot {
@@ -110,6 +111,26 @@ interface ScheduledAppointment {
procedureCodes?: string[]; procedureCodes?: string[];
} }
function deriveInsuranceSiteKey(provider: string | null | undefined): string {
const p = (provider || "").toLowerCase().trim();
if (!p) return "";
if (p.includes("masshealth") || p === "mh" || p === "mass health" || p.includes("cmsp")) 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 ins") || p === "deltains") return "DeltaIns";
if (p.includes("tufts") || p.includes("dentaquest") || p === "tuftssco") return "TuftsSCO";
if ((p.includes("united") && p.includes("sco")) || p === "unitedsco") return "UnitedSCO";
if (p.includes("bcbs") || p.includes("blue cross")) return "BCBS";
if (p.includes("united aapr") || p === "unitedaapr") return "UnitedAAPR";
if (p.includes("aetna")) return "Aetna";
if (p.includes("altus")) return "Altus";
if (p.includes("metlife")) return "MetlifeDental";
if (p.includes("cigna")) return "Cigna";
if (p.includes("delta wa") || p === "deltawa") return "DeltaWA";
if (p.includes("delta il") || p === "deltail") return "DeltaIL";
return "";
}
function appointmentCardColor(apt: ScheduledAppointment): string { function appointmentCardColor(apt: ScheduledAppointment): string {
if (apt.hasClaimWithNumber) return "bg-gray-500 text-white"; if (apt.hasClaimWithNumber) return "bg-gray-500 text-white";
if (apt.hasProcedures) return "bg-blue-500 text-white"; if (apt.hasProcedures) return "bg-blue-500 text-white";
@@ -177,6 +198,11 @@ export default function AppointmentsPage() {
const [isClaimingAiColumn, setIsClaimingAiColumn] = useState(false); const [isClaimingAiColumn, setIsClaimingAiColumn] = useState(false);
const [autoClaimEnabled, setAutoClaimEnabled] = useState(false); const [autoClaimEnabled, setAutoClaimEnabled] = useState(false);
const [autoClaimTime, setAutoClaimTime] = useState("19:00"); const [autoClaimTime, setAutoClaimTime] = useState("19:00");
const [autoConfirmEnabled, setAutoConfirmEnabled] = useState(
() => localStorage.getItem("ai_claim_auto_confirm") === "1"
);
// True while the current AI claim run should advance without manual Confirm clicks
const [aiClaimAutoRun, setAiClaimAutoRun] = useState(false);
const autoClaimFiredHourRef = useRef<number | null>(null); const autoClaimFiredHourRef = useRef<number | null>(null);
const [aiClaimModalOpen, setAiClaimModalOpen] = useState(false); const [aiClaimModalOpen, setAiClaimModalOpen] = useState(false);
const [aiClaimQueue, setAiClaimQueue] = useState<ScheduledAppointment[]>([]); const [aiClaimQueue, setAiClaimQueue] = useState<ScheduledAppointment[]>([]);
@@ -466,6 +492,7 @@ export default function AppointmentsPage() {
sessionStorage.setItem("ai_claim_queue", JSON.stringify({ ...parsed, pendingResume: false })); sessionStorage.setItem("ai_claim_queue", JSON.stringify({ ...parsed, pendingResume: false }));
setAiClaimQueue(queue); setAiClaimQueue(queue);
setAiClaimCurrentIndex(0); setAiClaimCurrentIndex(0);
setAiClaimAutoRun(!!parsed.autoRun);
setAiClaimModalOpen(true); setAiClaimModalOpen(true);
processAiClaimAtIndex(queue, 0); processAiClaimAtIndex(queue, 0);
} catch { } catch {
@@ -1515,6 +1542,42 @@ export default function AppointmentsPage() {
const apt = queue[index]!; const apt = queue[index]!;
const aptDate = typeof apt.date === "string" ? apt.date : formatLocalDate(apt.date as Date); const aptDate = typeof apt.date === "string" ? apt.date : formatLocalDate(apt.date as Date);
if (!apt.notes?.trim()) { if (!apt.notes?.trim()) {
if (apt.hasProcedures) {
setIsAiClaimProcessing(true);
setAiClaimCurrentData(null);
try {
const res = await apiRequest("GET", `/api/appointment-procedures/prefill-from-appointment/${apt.id}`);
const data = await res.json();
const matchedCodes = (data.procedures ?? []).map((p: any) => ({
code: p.procedureCode,
description: getDescriptionForCode(p.procedureCode) ?? "",
toothNumber: p.toothNumber ?? undefined,
toothSurface: p.toothSurface ?? undefined,
}));
setAiClaimCurrentData({
matchedCodes,
siteKey: deriveInsuranceSiteKey(apt.patientInsuranceProvider),
serviceDate: aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: "",
reply: "No notes on this appointment — using previously saved procedures instead.",
});
} catch {
setAiClaimCurrentData({
matchedCodes: [],
siteKey: "",
serviceDate: aptDate,
appointmentId: Number(apt.id),
patientName: apt.patientName,
notes: "",
reply: "No notes on this appointment, and failed to load saved procedures.",
});
} finally {
setIsAiClaimProcessing(false);
}
return;
}
setIsAiClaimProcessing(false); setIsAiClaimProcessing(false);
setAiClaimCurrentData({ setAiClaimCurrentData({
matchedCodes: [], matchedCodes: [],
@@ -1594,13 +1657,16 @@ export default function AppointmentsPage() {
toast({ title: "No unclaimed appointments", description: "All appointments in the selected columns are already claimed." }); toast({ title: "No unclaimed appointments", description: "All appointments in the selected columns are already claimed." });
return; return;
} }
const autoRun = autoConfirmEnabled || autoClaimEnabled;
sessionStorage.setItem("ai_claim_queue", JSON.stringify({ sessionStorage.setItem("ai_claim_queue", JSON.stringify({
appointments: unclaimed, appointments: unclaimed,
date: formattedSelectedDate, date: formattedSelectedDate,
pendingResume: false, pendingResume: false,
autoRun,
})); }));
setAiClaimQueue(unclaimed); setAiClaimQueue(unclaimed);
setAiClaimCurrentIndex(0); setAiClaimCurrentIndex(0);
setAiClaimAutoRun(autoRun);
setAiClaimModalOpen(true); setAiClaimModalOpen(true);
await processAiClaimAtIndex(unclaimed, 0); await processAiClaimAtIndex(unclaimed, 0);
}; };
@@ -1615,6 +1681,7 @@ export default function AppointmentsPage() {
appointments: fromCurrent, appointments: fromCurrent,
date: formattedSelectedDate, date: formattedSelectedDate,
pendingResume: true, pendingResume: true,
autoRun: aiClaimAutoRun,
})); }));
} else { } else {
sessionStorage.removeItem("ai_claim_queue"); sessionStorage.removeItem("ai_claim_queue");
@@ -1702,6 +1769,28 @@ export default function AppointmentsPage() {
} }
}; };
// Auto mode: advance the AI claim queue without manual Confirm clicks.
// Confirms when codes matched, skips otherwise (incl. CDT clarifications, which need human input).
useEffect(() => {
if (!aiClaimModalOpen || !aiClaimAutoRun || isAiClaimProcessing) return;
if (aiClaimCdtClarification) {
const t = setTimeout(() => {
setAiClaimCdtClarification(null);
handleAiClaimSkip();
}, 1500);
return () => clearTimeout(t);
}
if (!aiClaimCurrentData) return;
const t = setTimeout(() => {
if (aiClaimCurrentData.matchedCodes.length > 0) {
handleAiClaimConfirm();
} else {
handleAiClaimSkip();
}
}, 1500);
return () => clearTimeout(t);
}, [aiClaimModalOpen, aiClaimAutoRun, isAiClaimProcessing, aiClaimCurrentData, aiClaimCdtClarification]);
return ( return (
<div> <div>
<SeleniumTaskBanner <SeleniumTaskBanner
@@ -1849,6 +1938,19 @@ export default function AppointmentsPage() {
</span> </span>
</label> </label>
))} ))}
<div className="flex items-center gap-2 border-l pl-3 ml-1">
<Switch
id="auto-confirm-toggle"
checked={autoConfirmEnabled}
onCheckedChange={(v) => {
setAutoConfirmEnabled(v);
localStorage.setItem("ai_claim_auto_confirm", v ? "1" : "0");
}}
/>
<Label htmlFor="auto-confirm-toggle" className="text-sm font-medium cursor-pointer whitespace-nowrap">
Auto Confirm
</Label>
</div>
<div className="flex items-center gap-2 border-l pl-3 ml-1"> <div className="flex items-center gap-2 border-l pl-3 ml-1">
<Switch <Switch
id="auto-claim-toggle" id="auto-claim-toggle"
@@ -2427,6 +2529,9 @@ export default function AppointmentsPage() {
AI Claim Queue AI Claim Queue
</h3> </h3>
<span className="text-xs text-gray-500 font-medium"> <span className="text-xs text-gray-500 font-medium">
{aiClaimAutoRun && (
<span className="text-teal-600 mr-2">Auto</span>
)}
{aiClaimCurrentIndex + 1} / {aiClaimQueue.length} {aiClaimCurrentIndex + 1} / {aiClaimQueue.length}
</span> </span>
</div> </div>
+121
View File
@@ -7,6 +7,12 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Calendar as CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatLocalDate } from "@/utils/dateUtils";
import { ClaimForm } from "@/components/claims/claim-form"; import { ClaimForm } from "@/components/claims/claim-form";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
@@ -48,6 +54,12 @@ export default function ClaimsPage() {
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewPdfId, setPreviewPdfId] = useState<number | string | null>(null); const [previewPdfId, setPreviewPdfId] = useState<number | string | null>(null);
const [previewFallbackFilename, setPreviewFallbackFilename] = useState<string | null>(null); const [previewFallbackFilename, setPreviewFallbackFilename] = useState<string | null>(null);
// Preauth followup state
const [preauthFollowupFrom, setPreauthFollowupFrom] = useState<Date | undefined>(undefined);
const [preauthFollowupTo, setPreauthFollowupTo] = useState<Date | undefined>(undefined);
const [preauthFollowupFromOpen, setPreauthFollowupFromOpen] = useState(false);
const [preauthFollowupToOpen, setPreauthFollowupToOpen] = useState(false);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { status, message, show } = useAppSelector( const { status, message, show } = useAppSelector(
(state) => state.seleniumTasks.claimSubmit (state) => state.seleniumTasks.claimSubmit
@@ -1138,6 +1150,115 @@ export default function ClaimsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Preauth Followup Section */}
<Card>
<CardHeader>
<CardTitle>Preauth Followup</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2">
<Label className="flex items-center">From</Label>
<Popover
open={preauthFollowupFromOpen}
onOpenChange={setPreauthFollowupFromOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[140px] justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{preauthFollowupFrom
? formatLocalDate(preauthFollowupFrom)
: "Pick Date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto">
<Calendar
mode="single"
selected={preauthFollowupFrom}
onSelect={(date) => {
setPreauthFollowupFrom(date);
setPreauthFollowupFromOpen(false);
}}
onClose={() => setPreauthFollowupFromOpen(false)}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">
<Label className="flex items-center">To</Label>
<Popover
open={preauthFollowupToOpen}
onOpenChange={setPreauthFollowupToOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[140px] justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{preauthFollowupTo
? formatLocalDate(preauthFollowupTo)
: "Pick Date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto">
<Calendar
mode="single"
selected={preauthFollowupTo}
onSelect={(date) => {
setPreauthFollowupTo(date);
setPreauthFollowupToOpen(false);
}}
onClose={() => setPreauthFollowupToOpen(false)}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-wrap gap-2 items-center">
<Button
type="button"
variant="outline"
onClick={() => {
// TODO: define CCA PA followup logic
}}
>
CCA PA
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
// TODO: define United PA followup logic
}}
>
United PA
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
// TODO: define Tufts PA followup logic
}}
>
Tufts PA
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
// TODO: define All recent PA followup logic
}}
>
All recent PA
</Button>
</div>
</CardContent>
</Card>
{/* File Upload Zone */} {/* File Upload Zone */}
<ClaimDocumentsUploadMultiple /> <ClaimDocumentsUploadMultiple />
+127 -104
View File
@@ -10,6 +10,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -94,6 +95,8 @@ function waitForSeleniumJob(
}); });
} }
const PENDING_COPY_AGENT_SCREENSHOTS_KEY = "copy_agent_pending_screenshots";
export default function InsuranceStatusPage() { export default function InsuranceStatusPage() {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
@@ -120,6 +123,7 @@ export default function InsuranceStatusPage() {
const [isCheckingEligibilityHistory, setIsCheckingEligibilityHistory] = const [isCheckingEligibilityHistory, setIsCheckingEligibilityHistory] =
useState(false); useState(false);
const [isCheckingCMSP, setIsCheckingCMSP] = useState(false); const [isCheckingCMSP, setIsCheckingCMSP] = useState(false);
const [showPdfViewerOnComplete, setShowPdfViewerOnComplete] = useState(false);
// AI Call Insurance section // AI Call Insurance section
const [aiCallOpen, setAiCallOpen] = useState(false); const [aiCallOpen, setAiCallOpen] = useState(false);
@@ -171,6 +175,9 @@ export default function InsuranceStatusPage() {
const [triggerTarget, setTriggerTarget] = useState<string | null>(null); const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
const pendingScrollTo = useRef<string | null>(null); const pendingScrollTo = useRef<string | null>(null);
const [prefillTick, setPrefillTick] = useState(0); const [prefillTick, setPrefillTick] = useState(0);
// Set when the chatbot prefill supplies a DOB, so the auto-lookup-by-member-ID effect below
// doesn't clobber a deliberately hand-edited DOB with the (possibly stale/wrong) one on file.
const suppressAutoLookupDobRef = useRef(false);
// Prefill from chatbot // Prefill from chatbot
useEffect(() => { useEffect(() => {
@@ -181,18 +188,33 @@ export default function InsuranceStatusPage() {
const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw); const { memberId: id, dob, autoCheck: ac } = JSON.parse(raw);
if (id) setMemberId(id); if (id) setMemberId(id);
if (dob) { if (dob) {
// dob may arrive as MM/DD/YYYY or YYYY-MM-DD — normalize to YYYY-MM-DD // 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) // A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })() // this function — otherwise pendingAutoCheck/prefillTick below never get set and
: dob; // the auto-trigger effect silently never fires (no error, Selenium never launches).
setDateOfBirth(parseLocalDate(normalized)); try {
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));
suppressAutoLookupDobRef.current = true;
} catch (dobErr) {
console.error("[insurance-status] failed to parse prefilled DOB:", dob, dobErr);
toast({
title: "Invalid Date of Birth",
description: `Couldn't parse "${dob}" — please re-enter the Date of Birth below and try again.`,
variant: "destructive",
});
}
} }
if (ac) pendingAutoCheck.current = ac; if (ac) pendingAutoCheck.current = ac;
sessionStorage.removeItem("chatbot_eligibility"); sessionStorage.removeItem("chatbot_eligibility");
// Increment tick so the auto-trigger effect re-fires even when // Increment tick so the auto-trigger effect re-fires even when
// memberId/dob are unchanged (same patient submitted a second time). // memberId/dob are unchanged (same patient submitted a second time).
setPrefillTick((n) => n + 1); setPrefillTick((n) => n + 1);
} catch {} } catch (err) {
console.error("[insurance-status] failed to apply chatbot eligibility prefill:", err);
}
}; };
apply(); apply();
window.addEventListener("chatbot:eligibility-prefill", apply); window.addEventListener("chatbot:eligibility-prefill", apply);
@@ -201,6 +223,7 @@ export default function InsuranceStatusPage() {
}, []); }, []);
// Populate fields from selected patient // Populate fields from selected patient
const didMountSelectedPatientEffect = useRef(false);
useEffect(() => { useEffect(() => {
if (selectedPatient) { if (selectedPatient) {
setMemberId(selectedPatient.insuranceId ?? ""); setMemberId(selectedPatient.insuranceId ?? "");
@@ -212,12 +235,16 @@ export default function InsuranceStatusPage() {
? parseLocalDate(selectedPatient.dateOfBirth) ? parseLocalDate(selectedPatient.dateOfBirth)
: selectedPatient.dateOfBirth; : selectedPatient.dateOfBirth;
setDateOfBirth(dob ?? null); setDateOfBirth(dob ?? null);
} else { } else if (didMountSelectedPatientEffect.current) {
// Only clear on an actual deselection (user action) — not on the initial mount, where
// selectedPatient starts null by default and would otherwise wipe out values the
// chatbot-prefill effect (above) just set from sessionStorage on this same page load.
setMemberId(""); setMemberId("");
setFirstName(""); setFirstName("");
setLastName(""); setLastName("");
setDateOfBirth(null); setDateOfBirth(null);
} }
didMountSelectedPatientEffect.current = true;
}, [selectedPatient]); }, [selectedPatient]);
// Auto-lookup patient by member ID when typed manually // Auto-lookup patient by member ID when typed manually
@@ -233,11 +260,15 @@ export default function InsuranceStatusPage() {
if (patient) { if (patient) {
setFirstName(patient.firstName ?? ""); setFirstName(patient.firstName ?? "");
setLastName(patient.lastName ?? ""); setLastName(patient.lastName ?? "");
const dob = if (suppressAutoLookupDobRef.current) {
typeof patient.dateOfBirth === "string" suppressAutoLookupDobRef.current = false;
? parseLocalDate(patient.dateOfBirth) } else {
: patient.dateOfBirth ?? null; const dob =
setDateOfBirth(dob); typeof patient.dateOfBirth === "string"
? parseLocalDate(patient.dateOfBirth)
: patient.dateOfBirth ?? null;
setDateOfBirth(dob);
}
} }
} catch { } catch {
// silently ignore lookup errors // silently ignore lookup errors
@@ -286,6 +317,49 @@ export default function InsuranceStatusPage() {
return new Date() < cutoff; return new Date() < cutoff;
}; };
// If the Copy Agent page couldn't read a Member ID/DOB from a screenshot, it stashes the
// screenshot(s) here and lets the user complete the check manually. Once that check resolves
// to a real patient, attach the originally-captured screenshots to that patient's Attachments.
const flushPendingCopyAgentScreenshots = async (jobResult: any) => {
let raw: string | null = null;
try {
raw = sessionStorage.getItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
} catch {}
if (!raw || !jobResult?.patientId) return;
try {
sessionStorage.removeItem(PENDING_COPY_AGENT_SCREENSHOTS_KEY);
const items: { dataUrl: string; fileName: string }[] = JSON.parse(raw);
if (!items.length) return;
let patientName = `Patient ${jobResult.patientId}`;
try {
const res = await apiRequest("GET", `/api/patients/${jobResult.patientId}`);
const patient: Patient = await res.json();
const full = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (full) patientName = full;
} catch {}
const formData = new FormData();
formData.append("patientId", String(jobResult.patientId));
formData.append("patientName", patientName);
formData.append("screenshotBackup", "true");
for (const item of items) {
const blobRes = await fetch(item.dataUrl);
const blob = await blobRes.blob();
formData.append("files", new File([blob], `${item.fileName}.png`, { type: "image/png" }));
}
await apiRequest("POST", "/api/claims/upload-to-cloud", formData);
toast({
title: "Screenshots attached",
description: `Screenshots from Copy Agent were attached to ${patientName}'s attachments.`,
});
} catch (err) {
console.error("Failed to attach pending Copy Agent screenshots:", err);
}
};
// Shared: run MH eligibility selenium job, return jobResult or throw // Shared: run MH eligibility selenium job, return jobResult or throw
const runMHEligibilitySelenium = async (): Promise<any> => { const runMHEligibilitySelenium = async (): Promise<any> => {
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : ""; const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
@@ -325,9 +399,11 @@ export default function InsuranceStatusPage() {
}), }),
); );
return waitForSeleniumJob(jobId, (msg) => const jobResult = await waitForSeleniumJob(jobId, (msg) =>
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg })) dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
); );
await flushPendingCopyAgentScreenshots(jobResult);
return jobResult;
}; };
const handleAddPatient = async () => { const handleAddPatient = async () => {
@@ -636,13 +712,16 @@ export default function InsuranceStatusPage() {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg })) dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
); );
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "CMSP PDFs saved to Documents." })); dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "MH PDFs saved to Documents." }));
toast({ toast({
title: "Done", title: "Done",
description: "Eligibility, history, and accumulator PDFs saved.", description: "Eligibility, history, and accumulator PDFs saved.",
variant: "default", variant: "default",
}); });
const claimed = await tryClaimFromChatbot(selectedPatient?.id);
void tryAppointmentFromChatbot();
if (claimed) return;
setSelectedPatient(null); setSelectedPatient(null);
await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE }); await queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
processNextInQueue(); processNextInQueue();
@@ -650,14 +729,14 @@ export default function InsuranceStatusPage() {
// Open 4-panel modal // Open 4-panel modal
if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId || jobResult.accumulatorPdfFileId) { if (jobResult.pdfFileId || jobResult.memberDetailsPdfFileId || jobResult.historyPdfFileId || jobResult.accumulatorPdfFileId) {
setCmspEligibilityPdfId(jobResult.pdfFileId ?? null); setCmspEligibilityPdfId(jobResult.pdfFileId ?? null);
setCmspEligibilityFilename(jobResult.pdfFilename ?? `cmsp_eligibility_${memberId}.pdf`); setCmspEligibilityFilename(jobResult.pdfFilename ?? `mh_eligibility_${memberId}.pdf`);
setCmspMemberDetailsPdfId(jobResult.memberDetailsPdfFileId ?? null); setCmspMemberDetailsPdfId(jobResult.memberDetailsPdfFileId ?? null);
setCmspMemberDetailsFilename(jobResult.memberDetailsPdfFilename ?? `cmsp_member_details_${memberId}.pdf`); setCmspMemberDetailsFilename(jobResult.memberDetailsPdfFilename ?? `mh_member_details_${memberId}.pdf`);
setCmspHistoryPdfId(jobResult.historyPdfFileId ?? null); setCmspHistoryPdfId(jobResult.historyPdfFileId ?? null);
setCmspHistoryFilename(jobResult.historyPdfFilename ?? `cmsp_history_${memberId}.pdf`); setCmspHistoryFilename(jobResult.historyPdfFilename ?? `mh_history_${memberId}.pdf`);
setCmspAccumulatorPdfId(jobResult.accumulatorPdfFileId ?? null); setCmspAccumulatorPdfId(jobResult.accumulatorPdfFileId ?? null);
setCmspAccumulatorFilename(jobResult.accumulatorPdfFilename ?? `cmsp_accumulator_${memberId}.pdf`); setCmspAccumulatorFilename(jobResult.accumulatorPdfFilename ?? `mh_accumulator_${memberId}.pdf`);
setCmspPreviewOpen(true); if (showPdfViewerOnComplete) setCmspPreviewOpen(true);
} }
} catch (error: any) { } catch (error: any) {
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" })); dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: error.message || "Selenium submission failed" }));
@@ -673,11 +752,9 @@ export default function InsuranceStatusPage() {
const check = pendingAutoCheck.current; const check = pendingAutoCheck.current;
pendingAutoCheck.current = null; pendingAutoCheck.current = null;
if (check === "mh" || check === "mh-history") { if (check === "mh" || check === "mh-history") {
if (isUnder14(dateOfBirth)) { // Temporarily disabled: selenium_MH_eligibilityHistoryCheckWorker.py is no
handleCMSPButton(); // longer used — all MH eligibility checks route through the CMSP/MH worker.
} else { handleCMSPButton();
handleMHEligibilityHistoryButton();
}
} else if (check === "cmsp") { } else if (check === "cmsp") {
handleCMSPButton(); handleCMSPButton();
} else { } else {
@@ -765,6 +842,18 @@ export default function InsuranceStatusPage() {
const patient = await lookupRes.json(); const patient = await lookupRes.json();
if (!patient?.id) return; if (!patient?.id) return;
// The eligibility check that just ran saved the result to patient.status — only skip
// booking when insurance came back explicitly INACTIVE. ACTIVE and UNKNOWN (e.g. a
// scrape that couldn't confirm status but didn't find an ineligible record) still book.
if (patient.status === "INACTIVE") {
toast({
title: "No appointment created",
description: `${patient.firstName ?? ""} ${patient.lastName ?? ""} insurance is inactive — please add manually if needed.`.trim(),
variant: "destructive",
});
return;
}
const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id, date: useDate ?? undefined }); const apptRes = await apiRequest("POST", "/api/ai/create-appointment-today", { patientId: patient.id, date: useDate ?? undefined });
const apptData = await apptRes.json(); const apptData = await apptRes.json();
if (apptRes.ok) { if (apptRes.ok) {
@@ -932,82 +1021,7 @@ export default function InsuranceStatusPage() {
</div> </div>
</div> </div>
<div className="flex flex-col-2 gap-4">
<Button
onClick={() => handleMHEligibilityButton()}
className="w-full"
disabled={isCheckingEligibilityStatus}
>
{isCheckingEligibilityStatus ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
MH Eligibility
</>
)}
</Button>
<Button
onClick={() => handleMHEligibilityAppointmentButton()}
className="w-full"
disabled={isCheckingEligibilityAppointment}
>
{isCheckingEligibilityAppointment ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
MH Eligibility&Appointment
</>
)}
</Button>
<Button
onClick={() => handleMHEligibilityClaimsPreAuthButton()}
className="w-full"
disabled={isCheckingEligibilityClaimsPreAuth}
>
{isCheckingEligibilityClaimsPreAuth ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
MH Eligibility&Claims/PreAuth
</>
)}
</Button>
</div>
<div className="flex flex-col-2 gap-4 mt-4"> <div className="flex flex-col-2 gap-4 mt-4">
<Button
id="eligibility-mh-history"
className="w-full"
disabled={isCheckingEligibilityHistory}
onClick={() => handleMHEligibilityHistoryButton()}
>
{isCheckingEligibilityHistory ? (
<>
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<CheckCircle className="h-4 w-4 mr-2" />
MH Eligibility &amp; History
</>
)}
</Button>
<Button <Button
id="eligibility-cmsp" id="eligibility-cmsp"
className="w-full" className="w-full"
@@ -1022,12 +1036,23 @@ export default function InsuranceStatusPage() {
) : ( ) : (
<> <>
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
CMSP Eligibility &amp; History &amp; Remaining MH Eligibility &amp; Remaining &amp; History
</> </>
)} )}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2 mt-2">
<Switch
id="mh-show-pdf-viewer"
checked={showPdfViewerOnComplete}
onCheckedChange={setShowPdfViewerOnComplete}
/>
<Label htmlFor="mh-show-pdf-viewer" className="text-sm text-muted-foreground font-normal">
Show PDF viewer after check completes
</Label>
</div>
{/* TEMP PROVIDER BUTTONS */} {/* TEMP PROVIDER BUTTONS */}
<div id="eligibility-other-providers" className="space-y-4 mt-6"> <div id="eligibility-other-providers" className="space-y-4 mt-6">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-sm font-medium text-muted-foreground">
@@ -1375,6 +1400,7 @@ export default function InsuranceStatusPage() {
allowDelete={true} allowDelete={true}
allowCheckbox={true} allowCheckbox={true}
allowEdit={true} allowEdit={true}
showEligibilityColumns={true}
onSelectPatient={setSelectedPatient} onSelectPatient={setSelectedPatient}
/> />
</CardContent> </CardContent>
@@ -1391,7 +1417,6 @@ export default function InsuranceStatusPage() {
}} }}
pdfId={previewPdfId ?? undefined} pdfId={previewPdfId ?? undefined}
fallbackFilename={previewFallbackFilename ?? undefined} fallbackFilename={previewFallbackFilename ?? undefined}
autoDownload
/> />
{/* 4-panel modal for CMSP — eligibility, member details, history, accumulator */} {/* 4-panel modal for CMSP — eligibility, member details, history, accumulator */}
@@ -1408,13 +1433,12 @@ export default function InsuranceStatusPage() {
setCmspAccumulatorPdfId(null); setCmspAccumulatorPdfId(null);
setCmspAccumulatorFilename(null); setCmspAccumulatorFilename(null);
}} }}
title="CMSP Eligibility, History &amp; Remaining" title="MH Eligibility, Remaining &amp; History"
panels={[ panels={[
{ {
pdfId: cmspEligibilityPdfId, pdfId: cmspEligibilityPdfId,
fallbackFilename: cmspEligibilityFilename, fallbackFilename: cmspEligibilityFilename,
label: "Eligibility", label: "Eligibility",
autoDownload: true,
}, },
{ {
pdfId: cmspMemberDetailsPdfId, pdfId: cmspMemberDetailsPdfId,
@@ -1452,7 +1476,6 @@ export default function InsuranceStatusPage() {
pdfId: dualEligibilityPdfId, pdfId: dualEligibilityPdfId,
fallbackFilename: dualEligibilityFilename, fallbackFilename: dualEligibilityFilename,
label: "Eligibility", label: "Eligibility",
autoDownload: true,
}, },
{ {
pdfId: dualMemberDetailsPdfId, pdfId: dualMemberDetailsPdfId,
@@ -24,6 +24,7 @@ import {
Send, Send,
X, X,
MoonStar, MoonStar,
Bot,
} from "lucide-react"; } from "lucide-react";
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog"; import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
import { MessageThread } from "@/components/patient-connection/message-thread"; import { MessageThread } from "@/components/patient-connection/message-thread";
@@ -103,6 +104,32 @@ export default function PatientConnectionPage() {
}, },
}); });
const makeAiCallMutation = useMutation({
mutationFn: async ({
to,
patientId,
}: {
to: string;
patientId: number;
}) => {
return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId, purpose: "recall" });
},
onSuccess: () => {
toast({
title: "AI Call Started",
description: "Lisa is calling the patient now.",
});
},
onError: (error: any) => {
toast({
title: "AI Call Failed",
description:
error.message || "Unable to place the AI call. Please try again.",
variant: "destructive",
});
},
});
// Fetch all patients from database // Fetch all patients from database
const { data: patients = [], isLoading } = useQuery<Patient[]>({ const { data: patients = [], isLoading } = useQuery<Patient[]>({
queryKey: ["/api/patients"], queryKey: ["/api/patients"],
@@ -139,6 +166,22 @@ export default function PatientConnectionPage() {
}); });
}; };
// Handle AI-driven call via Twilio
const handleAiCall = (patient: Patient) => {
if (!patient.phone?.trim()) {
toast({
title: "No Phone Number",
description: "This patient does not have a phone number on file.",
variant: "destructive",
});
return;
}
makeAiCallMutation.mutate({
to: patient.phone,
patientId: Number(patient.id),
});
};
// Handle sending SMS // Handle sending SMS
const handleSMS = (patient: Patient) => { const handleSMS = (patient: Patient) => {
setSelectedPatient(patient); setSelectedPatient(patient);
@@ -343,6 +386,15 @@ export default function PatientConnectionPage() {
<Phone className="h-4 w-4 mr-1" /> <Phone className="h-4 w-4 mr-1" />
Call Call
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => handleAiCall(patient)}
data-testid={`button-ai-call-${patient.id}`}
>
<Bot className="h-4 w-4 mr-1" />
AI Call
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
"name": "patientdataextractorservice", "name": "patientdataextractorservice",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", "setup": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt",
"dev": ".venv/bin/python3 main.py" "dev": ".venv/bin/python3 main.py"
} }
} }
+1
View File
@@ -1,3 +1,4 @@
google_credentials.json google_credentials.json
astute-office-469222-k3-ba714473050d.json
__pycache__/ __pycache__/
+61 -7
View File
@@ -1,13 +1,67 @@
# Medical Billing OCR API (FastAPI) # Payment OCR Service (FastAPI)
FastAPI wrapper around a Google Cloud Vision OCR pipeline. Used by the Backend for
payment/EOB document extraction (`/extract/json`, `/extract/csv`, `/extract/pdf/json`)
and by the Windows Type Agent for exact-pixel text location (`/extract/words`).
## 1) Prereqs ## 1) Prereqs
- Google Cloud Vision service-account JSON.
- `GOOGLE_APPLICATION_CREDENTIALS` env var pointing to that JSON.
- Tesseract installed (for fallback OCR), and on PATH.
## 2) Install & run (local) - Python 3.
- A Google Cloud Vision service-account key (see step 2 below).
## 2) Create a Google Cloud Vision API key
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select (or
create) the project this service should use.
2. **APIs & Services → Library** → search for **"Cloud Vision API"** → click **Enable**
(skip if already enabled).
3. **IAM & Admin → Service Accounts** → either pick an existing service account for
this app, or **Create Service Account** (any name, e.g. `ocr-service`; no special
roles are required beyond default — Vision API access comes from the API being
enabled on the project, not a role grant).
4. Open that service account → **Keys** tab → **Add Key → Create new key → JSON**.
This immediately downloads a `.json` file to your browser's Downloads folder —
**this is the only time the private key content is shown**, so keep the file safe
(a password manager or secure backup, not just Downloads).
## 3) Install the key
1. Move (don't just copy, to avoid leaving stray copies around) the downloaded JSON
file into this folder (`apps/PaymentOCRService/`).
2. Rename it to exactly `google_credentials.json` — this is the filename `.env`
already expects:
```bash
mv ~/Downloads/<your-downloaded-file>.json apps/PaymentOCRService/google_credentials.json
```
3. This filename is gitignored on purpose — **never commit it**. If a key is ever
accidentally exposed (committed, pasted, screenshotted), go back to the Keys tab
in step 2 and delete it, then generate a new one.
## 4) Install & run (local)
```bash ```bash
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
export GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/service-account.json uvicorn main:app --host 0.0.0.0 --port 5003
uvicorn app.main:app --reload --port 8080 ```
Or just `python main.py` — it reads `HOST`/`PORT` from `.env` (defaults to
`0.0.0.0:5003`) and `GOOGLE_APPLICATION_CREDENTIALS=google_credentials.json` from the
same file.
Verify it's working:
```bash
curl localhost:5003/health
# should report "GOOGLE_APPLICATION_CREDENTIALS set: True"
```
## 5) Endpoints
- `POST /extract/json`, `/extract/csv`, `/extract/csvtext` — payment/EOB document
images → structured rows (deskew + line-grouping + domain extraction pipeline).
- `POST /extract/pdf/json` — same, for remittance-advice PDFs.
- `POST /extract/words` — raw OCR only: a flat list of every detected word with its
exact pixel bounding box (`left`, `top`, `w`, `h`, `cx`, `cy`). Used by the Windows
Type Agent to click exact text locations instead of relying on an AI-vision
estimate.
- `GET /health`, `GET /status` — liveness/credential check, active/queued job counts.
+37
View File
@@ -5,11 +5,13 @@ from typing import List, Optional
import io import io
import os import os
import asyncio import asyncio
import tempfile
import uvicorn import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
from complete_pipeline_adapter import process_images_to_rows,rows_to_csv_bytes from complete_pipeline_adapter import process_images_to_rows,rows_to_csv_bytes
from complete_pipeline import extract_words_and_text
from pdf_extractor import extract_ra_pdf from pdf_extractor import extract_ra_pdf
app = FastAPI( app = FastAPI(
@@ -98,6 +100,41 @@ async def extract_json(files: List[UploadFile] = File(...)):
async with lock: async with lock:
active_jobs -= 1 active_jobs -= 1
@app.post("/extract/words")
async def extract_words(file: UploadFile = File(...)):
"""Raw OCR only — no deskew/line-grouping/payment-domain logic, just the
flat word list with pixel bounding boxes. Used by the Windows Type Agent
to locate exact click targets (e.g. a patient name) instead of relying
on an AI-vision estimate of where text is."""
_validate_files([file])
async with lock:
global waiting_jobs
waiting_jobs += 1
async with semaphore:
async with lock:
waiting_jobs -= 1
global active_jobs
active_jobs += 1
tmp_path = None
try:
suffix = os.path.splitext(file.filename or "")[1] or ".png"
blob = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(blob)
tmp_path = tmp.name
words, _ = extract_words_and_text(tmp_path)
return JSONResponse(content={"words": words})
except Exception as e:
raise HTTPException(status_code=500, detail=f"OCR error: {e}")
finally:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
async with lock:
active_jobs -= 1
@app.post("/extract/csvtext", response_class=PlainTextResponse) @app.post("/extract/csvtext", response_class=PlainTextResponse)
async def extract_csvtext(files: List[UploadFile] = File(...)): async def extract_csvtext(files: List[UploadFile] = File(...)):
_validate_files(files) _validate_files(files)
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "paymentocrservice", "name": "paymentocrservice",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", "setup": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt",
"dev": ".venv/bin/python3 main.py" "dev": ".venv/bin/python3 main.py"
} }
} }
+1 -1
View File
@@ -2,6 +2,6 @@
"name": "seleniumservice", "name": "seleniumservice",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" "setup": "python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
} }
} }
@@ -446,14 +446,13 @@ class AutomationCCAClaimSubmit:
time.sleep(1) time.sleep(1)
print(f"[CCA Claim step4] Capturing dashboard — URL: {self.driver.current_url}") print(f"[CCA Claim step4] Capturing dashboard — URL: {self.driver.current_url}")
# Extract Encounter ID from the LAST row (newest claim is at the bottom) # Extract Encounter ID from the FIRST row (newest claim is at the top)
claim_number = None claim_number = None
# Try table cells first (Encounter ID is the first column) # Try table cells first (Encounter ID is the first column)
for sel in [ for sel in [
(By.XPATH, "//table//tbody//tr[last()]/td[1]"), (By.XPATH, "//table//tbody//tr[1]/td[1]"),
(By.XPATH, "//*[contains(@class,'wbx-table-results')]//tr[last()]/td[1]"), (By.XPATH, "//*[contains(@class,'wbx-table-results')]//tr[1]/td[1]"),
(By.XPATH, "//table//tbody//tr[last()-0]/td[1]"),
]: ]:
try: try:
cell = WebDriverWait(self.driver, 8).until( cell = WebDriverWait(self.driver, 8).until(
@@ -461,7 +460,7 @@ class AutomationCCAClaimSubmit:
text = cell.text.strip() text = cell.text.strip()
if re.match(r'\d{10,}', text): if re.match(r'\d{10,}', text):
claim_number = text claim_number = text
print(f"[CCA Claim step4] Encounter ID from last row: {claim_number}") print(f"[CCA Claim step4] Encounter ID from top row: {claim_number}")
break break
except Exception: except Exception:
continue continue
@@ -591,14 +590,12 @@ class AutomationCCAClaimSubmit:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _build_col_map(self): def _build_col_map(self):
""" """
Map column names to 1-based td positions using ej-mappingname divs. Map column names to 1-based td positions using ej-mappingname divs,
Falls back to hardcoded positions based on the known PDF column order. in the order they actually appear in the live grid header (1-based
index = td position). Falls back to hardcoded positions (stale —
do not trust for BILLED_AMOUNT) only if the live scan fails.
""" """
# Hardcoded based on PDF column order: fallback_col_map = {
# rownum(1), Code(2), Desc(3), Tooth(4),
# Surf1-5(5-9), OralCavity1-4(10-13), DiagPtr1-4(14-17),
# EPSDT(18), Qty(19), Auth(20), ServiceDate(21), BilledAmt(22)
col_map = {
"CODE": 2, "CODE": 2,
"TOOTH": 4, "TOOTH": 4,
"SURF1": 5, "S1": 5, "SURF1": 5, "S1": 5,
@@ -608,10 +605,9 @@ class AutomationCCAClaimSubmit:
"SURF5": 9, "S5": 9, "SURF5": 9, "S5": 9,
"QUANTITY": 19, "QTY": 19, "QUANTITY": 19, "QTY": 19,
"DOS": 21, "SERVICE DATE": 21, "SERVICEDATE": 21, "DOS": 21, "SERVICE DATE": 21, "SERVICEDATE": 21,
"BILLED_AMOUNT": 22, "BILLED AMT": 22, "BILLED A": 22, "BILLED_AMOUNT": 19, "BILLED AMT": 19, "BILLED A": 19,
} }
# Try to confirm/override positions using ej-mappingname divs in header
try: try:
divs = self.driver.find_elements( divs = self.driver.find_elements(
By.XPATH, By.XPATH,
@@ -619,14 +615,29 @@ class AutomationCCAClaimSubmit:
"//div[@id='ServicesGrid']//div[@ej-mappingname]" "//div[@id='ServicesGrid']//div[@ej-mappingname]"
) )
print(f"[CCA Claim grid] Found {len(divs)} ej-mappingname header divs") print(f"[CCA Claim grid] Found {len(divs)} ej-mappingname header divs")
for div in divs: col_map = {}
mapping = div.get_attribute("ej-mappingname") or "" for i, div in enumerate(divs):
mapping = (div.get_attribute("ej-mappingname") or "").strip().upper()
text = div.text.strip() text = div.text.strip()
print(f"[CCA Claim grid] Header: mapping={mapping!r} text={text!r}") print(f"[CCA Claim grid] Header[{i+1}]: mapping={mapping!r} text={text!r}")
if mapping:
col_map[mapping] = i + 1
# Aliases for the surface columns, which have empty mapping
# names in some grid states — fill in from SURFACE1..5 if present.
for n in range(1, 6):
key = f"SURFACE{n}"
if key in col_map:
col_map[f"SURF{n}"] = col_map[key]
col_map[f"S{n}"] = col_map[key]
if col_map:
return col_map
except Exception as e: except Exception as e:
print(f"[CCA Claim grid] Header scan error: {e}") print(f"[CCA Claim grid] Header scan error: {e}")
return col_map print("[CCA Claim grid] Falling back to hardcoded column map")
return fallback_col_map
def _dbl_click_col(self, row_num, col_idx): def _dbl_click_col(self, row_num, col_idx):
"""Double-click the cell at (row_num, col_idx) — both 1-based. """Double-click the cell at (row_num, col_idx) — both 1-based.
@@ -733,38 +744,51 @@ class AutomationCCAClaimSubmit:
cell_clicked = False cell_clicked = False
# --- Single-click cell to activate (yellow), then fill input --- # --- Single-click cell to activate (yellow), then fill input ---
def click_cell_and_fill(col_expr, input_id, value): def click_cell_and_fill(col_expr, input_id, value, row_timeout=6):
""" """
Click the cell at (row_num, col_expr) to activate it (turns yellow), Click the cell at (row_num, col_expr) to activate it (turns yellow),
then fill the pre-rendered input by ID. then fill the pre-rendered input by ID.
col_expr can be a number like 2 or an XPath expression like 'last()' or 'last()-1'. col_expr can be a number like 2 or an XPath expression like 'last()' or 'last()-1'.
Polls for up to row_timeout seconds since a new row is rendered
asynchronously by the grid after the previous row commits.
""" """
# Find the cell in the grid CONTENT rows (not headers) xpaths = [
cell = None
for xpath in [
f"(//div[contains(@class,'e-gridcontent')]//tr)[{row_num}]/td[{col_expr}]", f"(//div[contains(@class,'e-gridcontent')]//tr)[{row_num}]/td[{col_expr}]",
f"(//div[contains(@class,'e-content')]//tr)[{row_num}]/td[{col_expr}]", f"(//div[contains(@class,'e-content')]//tr)[{row_num}]/td[{col_expr}]",
f"(//div[@id='Services']//div[contains(@class,'e-content')]//tr)[{row_num}]/td[{col_expr}]", f"(//div[@id='Services']//div[contains(@class,'e-content')]//tr)[{row_num}]/td[{col_expr}]",
f"(//tr[@aria-rowindex='{row_num-1}'])/td[{col_expr}]", f"(//tr[@aria-rowindex='{row_num-1}'])/td[{col_expr}]",
]: ]
try:
cell = self.driver.find_element(By.XPATH, xpath)
break
except Exception:
continue
if cell: deadline = time.monotonic() + row_timeout
try: clicked = False
self.driver.execute_script( while time.monotonic() < deadline and not clicked:
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});", cell = None
cell) for xpath in xpaths:
cell.click() try:
cell = self.driver.find_element(By.XPATH, xpath)
break
except Exception:
continue
if cell:
try:
self.driver.execute_script(
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});",
cell)
cell.click()
clicked = True
time.sleep(0.3)
print(f"[CCA Claim grid] Clicked row={row_num} col={col_expr}")
except Exception as e:
print(f"[CCA Claim grid] Cell click failed, retrying: {e}")
time.sleep(0.3)
else:
print(f"[CCA Claim grid] Cell not found yet row={row_num} col={col_expr}, retrying")
time.sleep(0.3) time.sleep(0.3)
print(f"[CCA Claim grid] Clicked row={row_num} col={col_idx}")
except Exception as e: if not clicked:
print(f"[CCA Claim grid] Cell click failed: {e}") print(f"[CCA Claim grid] Gave up clicking row={row_num} col={col_expr} after {row_timeout}s")
else: return False
print(f"[CCA Claim grid] Cell not found row={row_num} col={col_idx}")
# Fill the pre-rendered input # Fill the pre-rendered input
try: try:
@@ -785,23 +809,31 @@ class AutomationCCAClaimSubmit:
print(f"[CCA Claim grid] Fill {input_id} failed: {e}") print(f"[CCA Claim grid] Fill {input_id} failed: {e}")
return False return False
# CODE (col 2) # CODE
click_cell_and_fill(2, "ServicesCODE", code) code_ok = click_cell_and_fill(col_map["CODE"], "ServicesCODE", code)
# TOOTH (col 4) — skip if empty # TOOTH — skip if empty
tooth_ok = True
if tooth: if tooth:
click_cell_and_fill(4, "ServicesTOOTH", tooth) tooth_ok = click_cell_and_fill(col_map["TOOTH"], "ServicesTOOTH", tooth)
# Surfaces (col 5-9) — skip if empty # Surfaces — skip if empty
if surface_chars: if surface_chars:
surf_ids = ["ServicesSURF1","ServicesSURF2","ServicesSURF3",
"ServicesSURF4","ServicesSURF5"]
for si, char in enumerate(surface_chars[:5]): for si, char in enumerate(surface_chars[:5]):
surf_ids = ["ServicesSURF1","ServicesSURF2","ServicesSURF3", click_cell_and_fill(col_map[f"SURF{si+1}"], surf_ids[si], char)
"ServicesSURF4","ServicesSURF5"]
click_cell_and_fill(5 + si, surf_ids[si], char)
# Billed Amount — last column # Billed Amount
# QTY and Service Date auto-fill after CODE is entered and this cell is clicked # QTY and Service Date auto-fill after CODE is entered and this cell is clicked
click_cell_and_fill("last()", "ServicesBILLED_AMOUNT", billed_str) billed_ok = click_cell_and_fill(col_map["BILLED_AMOUNT"], "ServicesBILLED_AMOUNT", billed_str)
if not (code_ok and tooth_ok and billed_ok):
missing = [name for name, ok in
[("code", code_ok), ("tooth", tooth_ok), ("billed amount", billed_ok)]
if not ok]
return (f"ERROR: failed to fill {', '.join(missing)} for service line "
f"{idx+1} (code={code}) — claim NOT submitted")
time.sleep(0.3) time.sleep(0.3)
@@ -234,7 +234,7 @@ class AutomationCMSPEligibilityHistoryRemainingCheck:
print("No new tab for eligibility; printing current page directly") print("No new tab for eligibility; printing current page directly")
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
pdf_filename = f"cmsp_eligibility_{safe_member}.pdf" pdf_filename = f"mh_eligibility_{safe_member}.pdf"
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True}) pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True})
pdf_bytes = base64.b64decode(pdf_data["data"]) pdf_bytes = base64.b64decode(pdf_data["data"])
pdf_path = os.path.join(self.download_dir, pdf_filename) pdf_path = os.path.join(self.download_dir, pdf_filename)
@@ -279,14 +279,57 @@ class AutomationCMSPEligibilityHistoryRemainingCheck:
# ── step 3b — print member details page as PDF via CDP ─────────────────────── # ── step 3b — print member details page as PDF via CDP ───────────────────────
def extract_third_party_insurance(self):
"""Reads the "Third Party Liability" table (Policy Number | Coverage Type |
Effective Date | Termination Date | Insurer Name | Insurer Payment Order)
and returns the Insurer Name(s), or None if the table has no rows
(i.e. the member has no third-party/2nd insurance).
"""
try:
tables = self.driver.find_elements(By.TAG_NAME, "table")
for table in tables:
rows = table.find_elements(By.TAG_NAME, "tr")
if not rows:
continue
header_cells = [
c.text.strip().lower()
for c in (rows[0].find_elements(By.TAG_NAME, "th") or rows[0].find_elements(By.TAG_NAME, "td"))
]
if "insurer name" not in header_cells:
continue
insurer_idx = header_cells.index("insurer name")
insurers = []
for row in rows[1:]:
cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) <= insurer_idx:
continue
name = cells[insurer_idx].text.strip()
if name:
insurers.append(name)
third_party = ", ".join(insurers) if insurers else None
self.extracted_data["third_party_insurance"] = third_party
print(f"[extract_third_party_insurance] {third_party!r}")
return third_party
print("[extract_third_party_insurance] No 'Third Party Liability' table found")
return None
except Exception as e:
print(f"[extract_third_party_insurance] failed: {e}")
return None
def step3b_member_details_pdf(self): def step3b_member_details_pdf(self):
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
wait.until(lambda d: d.execute_script("return document.readyState") == "complete") wait.until(lambda d: d.execute_script("return document.readyState") == "complete")
time.sleep(2) time.sleep(2)
self.extract_third_party_insurance()
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
pdf_filename = f"cmsp_member_details_{safe_member}.pdf" pdf_filename = f"mh_member_details_{safe_member}.pdf"
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True}) pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True})
pdf_bytes = base64.b64decode(pdf_data["data"]) pdf_bytes = base64.b64decode(pdf_data["data"])
pdf_path = os.path.join(self.download_dir, pdf_filename) pdf_path = os.path.join(self.download_dir, pdf_filename)
@@ -341,7 +384,7 @@ class AutomationCMSPEligibilityHistoryRemainingCheck:
time.sleep(3) time.sleep(3)
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
pdf_filename = f"cmsp_history_{safe_member}.pdf" pdf_filename = f"mh_history_{safe_member}.pdf"
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True}) pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True})
pdf_bytes = base64.b64decode(pdf_data["data"]) pdf_bytes = base64.b64decode(pdf_data["data"])
pdf_path = os.path.join(self.download_dir, pdf_filename) pdf_path = os.path.join(self.download_dir, pdf_filename)
@@ -353,7 +396,68 @@ class AutomationCMSPEligibilityHistoryRemainingCheck:
print(f"[step5_service_history_pdf] failed: {e}") print(f"[step5_service_history_pdf] failed: {e}")
return None return None
# ── step 6 — Tab B: accumulator → wait for data → CDP print ───────────────── # ── step 6 — Tab B: accumulator → extract remaining → CDP print ─────────────
def extract_remaining_amount(self):
"""Reads the accumulator benefits table and returns the Remaining amount.
Layout (see sample PDF "Accumulator Details"):
Type | Level | Network | Benefit Period | Period | Limit | Applied | Remaining
Prefers the Type="Maximum", Level="Individual" row; falls back to the
first data row with a non-empty Remaining cell if that combination
isn't found (payer may only return one row, or reorder columns).
"""
try:
wait = WebDriverWait(self.driver, 15)
wait.until(EC.presence_of_element_located((By.TAG_NAME, "table")))
tables = self.driver.find_elements(By.TAG_NAME, "table")
for table in tables:
rows = table.find_elements(By.TAG_NAME, "tr")
if not rows:
continue
header_cells = [
c.text.strip().lower()
for c in (rows[0].find_elements(By.TAG_NAME, "th") or rows[0].find_elements(By.TAG_NAME, "td"))
]
if "remaining" not in header_cells:
continue
remaining_idx = header_cells.index("remaining")
type_idx = header_cells.index("type") if "type" in header_cells else None
level_idx = header_cells.index("level") if "level" in header_cells else None
fallback_value = None
best_value = None
for row in rows[1:]:
cells = row.find_elements(By.TAG_NAME, "td")
if len(cells) <= remaining_idx:
continue
cell_texts = [c.text.strip() for c in cells]
value = cell_texts[remaining_idx]
if not value:
continue
if fallback_value is None:
fallback_value = value
type_ok = type_idx is None or "maximum" in cell_texts[type_idx].lower()
level_ok = level_idx is None or "individual" in cell_texts[level_idx].lower()
if type_ok and level_ok:
best_value = value
break
remaining_value = best_value or fallback_value
if remaining_value:
self.extracted_data["remaining_amount"] = remaining_value
print(f"[extract_remaining_amount] Remaining: {remaining_value}")
return remaining_value
print("[extract_remaining_amount] No accumulator table with a 'Remaining' column found")
return None
except Exception as e:
print(f"[extract_remaining_amount] failed: {e}")
return None
def step6_accumulator_pdf(self): def step6_accumulator_pdf(self):
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
@@ -379,8 +483,10 @@ class AutomationCMSPEligibilityHistoryRemainingCheck:
wait.until(lambda d: d.execute_script("return document.readyState") == "complete") wait.until(lambda d: d.execute_script("return document.readyState") == "complete")
time.sleep(4) time.sleep(4)
self.extract_remaining_amount()
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.") safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
pdf_filename = f"cmsp_accumulator_{safe_member}.pdf" pdf_filename = f"mh_accumulator_{safe_member}.pdf"
pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True}) pdf_data = self.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True})
pdf_bytes = base64.b64decode(pdf_data["data"]) pdf_bytes = base64.b64decode(pdf_data["data"])
pdf_path = os.path.join(self.download_dir, pdf_filename) pdf_path = os.path.join(self.download_dir, pdf_filename)
@@ -366,16 +366,26 @@ class AutomationDeltaDentalMAEligibilityCheck:
search_btn.click() search_btn.click()
print("[DDMA step1] Clicked Search button") print("[DDMA step1] Clicked Search button")
# Wait for the Members results table to render # Wait for either the Members results table to render, or (when the
# Subscriber ID + DOB match exactly one family member) DDMA to skip
# the results list and navigate straight to the patient detail page.
# Note: the detail page URL is nested UNDER the search page path
# (".../check-patient-benefits-search/check-patient-benefit-details"),
# so "check-patient-benefits-search" alone can't distinguish them —
# only the detail page's own "-benefit-details" segment can.
try: try:
WebDriverWait(self.driver, 15).until( WebDriverWait(self.driver, 15).until(
EC.presence_of_element_located((By.XPATH, "//table//tbody//tr")) lambda d: d.find_elements(By.XPATH, "//table//tbody//tr")
or "check-patient-benefit-details" in d.current_url.lower()
) )
time.sleep(2) time.sleep(2)
except TimeoutException: except TimeoutException:
print("[DDMA step1] Warning: No results table appeared within timeout") print("[DDMA step1] Warning: No results table appeared within timeout")
return "ERROR: INVALID SEARCH CRITERIA" return "ERROR: INVALID SEARCH CRITERIA"
if "check-patient-benefit-details" in self.driver.current_url.lower():
print(f"[DDMA step1] Search navigated directly to detail page: {self.driver.current_url}")
print("[DDMA step1] Search completed successfully") print("[DDMA step1] Search completed successfully")
return "Success" return "Success"
@@ -390,26 +400,40 @@ class AutomationDeltaDentalMAEligibilityCheck:
try: try:
import re import re
# Wait for results table, then pause for full render
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
time.sleep(2) # Let the row content fully render after table appears
except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout")
eligibilityText = "unknown" eligibilityText = "unknown"
foundMemberId = self.memberId or "" foundMemberId = self.memberId or ""
patientName = "" # If the caller already knows this patient (e.g. an existing patient record
# being rechecked), use that name directly — no need to scrape/parse it off
# the DDMA page, only the eligibility status is actually needed.
patientName = f"{self.firstName} {self.lastName}".strip() if self.firstName and self.lastName else ""
known_patient_name = bool(patientName)
# The Members table lists the whole family sharing this subscriber ID — # DDMA now sometimes skips the family-members results table entirely and
# find the row matching this patient's DOB (falls back to last name, then first row). # navigates straight to the single-patient detail page (when Subscriber ID +
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr") # DOB match exactly one person). In that case there's no members table to
print(f"[DDMA step2] Found {len(rows)} family member row(s)") # parse here — skip straight to the detail-page fallback extraction below.
already_on_detail_page = "check-patient-benefit-details" in self.driver.current_url.lower()
rows = []
target_row = None target_row = None
if not already_on_detail_page:
# Wait for results table, then pause for full render
try:
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.XPATH, "//tbody//tr"))
)
time.sleep(2) # Let the row content fully render after table appears
except TimeoutException:
print("[DDMA step2] Warning: Results table not found within timeout")
# The Members table lists the whole family sharing this subscriber ID —
# find the row matching this patient's DOB (falls back to last name, then first row).
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr")
print(f"[DDMA step2] Found {len(rows)} family member row(s)")
else:
print(f"[DDMA step2] Already on detail page: {self.driver.current_url}")
if self.dateOfBirth and rows: if self.dateOfBirth and rows:
try: try:
dob_parts = self.dateOfBirth.split("-") dob_parts = self.dateOfBirth.split("-")
@@ -436,17 +460,17 @@ class AutomationDeltaDentalMAEligibilityCheck:
if target_row is not None: if target_row is not None:
try: try:
cells = target_row.find_elements(By.XPATH, "./td") cells = target_row.find_elements(By.XPATH, "./td")
if cells: if cells and not known_patient_name:
patientName = cells[0].text.strip() patientName = cells[0].text.strip()
print(f"[DDMA step2] Extracted patient name from row: '{patientName}'") print(f"[DDMA step2] Extracted patient name from row: '{patientName}'")
if len(cells) >= 2 and cells[1].text.strip(): if len(cells) >= 2 and cells[1].text.strip():
foundMemberId = cells[1].text.strip() foundMemberId = cells[1].text.strip()
if len(cells) >= 7: if len(cells) >= 7:
status_text = cells[6].text.strip().lower() status_text = cells[6].text.strip().lower()
if "active" in status_text: if "inactive" in status_text or "termed" in status_text:
eligibilityText = "active"
elif "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive" eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else: else:
eligibilityText = status_text eligibilityText = status_text
print(f"[DDMA step2] Found eligibility status: {eligibilityText}") print(f"[DDMA step2] Found eligibility status: {eligibilityText}")
@@ -463,10 +487,10 @@ class AutomationDeltaDentalMAEligibilityCheck:
else: else:
print("[DDMA step2] Warning: No family member rows found in search results") print("[DDMA step2] Warning: No family member rows found in search results")
# Wait for the detail page to load (URL moves away from the search page) # Wait for the detail page to load (URL gains the "-benefit-details" segment)
try: try:
WebDriverWait(self.driver, 15).until( WebDriverWait(self.driver, 15).until(
lambda d: "check-patient-benefits-search" not in d.current_url lambda d: "check-patient-benefit-details" in d.current_url.lower()
) )
print(f"[DDMA step2] Navigated to detail page: {self.driver.current_url}") print(f"[DDMA step2] Navigated to detail page: {self.driver.current_url}")
except Exception: except Exception:
@@ -522,11 +546,13 @@ class AutomationDeltaDentalMAEligibilityCheck:
# forward past accessibility text to the first plausible name value. # forward past accessibility text to the first plausible name value.
for i, line in enumerate(page_lines): for i, line in enumerate(page_lines):
# Case 1: "Name : Value" or "Name: Value" on the same line # Case 1: "Name : Value" or "Name: Value" on the same line
# (DDMA's detail page labels this "Subscriber name:", so that
# prefix must be accepted rather than excluded)
same_line = re.match( same_line = re.match(
r'^(?:member\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE r'^(?:member\s+|subscriber\s+)?name\s*[:\-]\s*(.+)$', line, re.IGNORECASE
) )
if same_line and not re.search( if same_line and not re.search(
r'(provider|group|subscriber|plan)\s+name', line, re.IGNORECASE r'(provider|group|plan)\s+name', line, re.IGNORECASE
): ):
candidate = same_line.group(1).strip() candidate = same_line.group(1).strip()
if looks_like_name(candidate): if looks_like_name(candidate):
@@ -535,7 +561,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
break break
# Case 2: "Name" or "Name:" alone on a line — scan forward for value # Case 2: "Name" or "Name:" alone on a line — scan forward for value
elif re.match(r'^(?:member\s+)?name\s*:?\s*$', line, re.IGNORECASE): elif re.match(r'^(?:member\s+|subscriber\s+)?name\s*:?\s*$', line, re.IGNORECASE):
for j in range(i + 1, min(i + 6, len(page_lines))): for j in range(i + 1, min(i + 6, len(page_lines))):
candidate = page_lines[j].strip() candidate = page_lines[j].strip()
if looks_like_name(candidate): if looks_like_name(candidate):
@@ -554,6 +580,27 @@ class AutomationDeltaDentalMAEligibilityCheck:
if not patientName: if not patientName:
print("[DDMA step2] Could not extract patient name from detail page") print("[DDMA step2] Could not extract patient name from detail page")
# Fallback: refine eligibility status from the detail page's "Coverage is:"
# label if the family-table row extraction didn't set it (e.g. DDMA
# navigated straight to the detail page, skipping the members table).
if eligibilityText == "unknown":
try:
page_text = self.driver.execute_script("return document.body.innerText;")
coverage_match = re.search(
r'coverage\s+is\s*[:\-]?\s*([A-Za-z]+)', page_text, re.IGNORECASE
)
if coverage_match:
status_text = coverage_match.group(1).strip().lower()
if "inactive" in status_text or "termed" in status_text:
eligibilityText = "inactive"
elif "active" in status_text:
eligibilityText = "active"
else:
eligibilityText = status_text
print(f"[DDMA step2] Extracted eligibility from 'Coverage is:' label: '{eligibilityText}'")
except Exception as e:
print(f"[DDMA step2] Could not extract eligibility from 'Coverage is:' label: {e}")
# Wait for the page to be fully ready before capturing PDF # Wait for the page to be fully ready before capturing PDF
try: try:
WebDriverWait(self.driver, 30).until( WebDriverWait(self.driver, 30).until(
@@ -552,18 +552,18 @@ class AutomationTuftsSCOClaimSubmit:
inp.send_keys(str(value)) inp.send_keys(str(value))
time.sleep(0.5) time.sleep(0.5)
listbox_id = inp.get_attribute("aria-controls") or "" listbox_id = inp.get_attribute("aria-controls") or ""
scope = f"//*[@id='{listbox_id}']" if listbox_id else "//*[@role='listbox']"
try: try:
if listbox_id: try:
option = WebDriverWait(self.driver, 4).until( option = WebDriverWait(self.driver, 4).until(
EC.element_to_be_clickable((By.XPATH, EC.element_to_be_clickable((By.XPATH,
f"//*[@id='{listbox_id}']//*[@role='option'][1]" f"{scope}//*[@role='option' and contains(normalize-space(.),'{value}')]"
)) ))
) )
else: except TimeoutException:
option = WebDriverWait(self.driver, 4).until( option = WebDriverWait(self.driver, 2).until(
EC.element_to_be_clickable((By.XPATH, EC.element_to_be_clickable((By.XPATH,
f"//*[@role='listbox']//*[@role='option' and contains(normalize-space(.),'{value}')]" f"{scope}//*[@role='option'][1]"
f" | //*[@role='listbox']//*[@role='option'][1]"
)) ))
) )
option.click() option.click()
@@ -752,27 +752,20 @@ class AutomationTuftsSCOClaimSubmit:
print(f"[TuftsSCO Claim step5] Attaching: {abs_path}") print(f"[TuftsSCO Claim step5] Attaching: {abs_path}")
try: try:
add_file_btn = WebDriverWait(self.driver, 10).until( # Don't click the "Add a file" button: it's wired to the hidden
EC.element_to_be_clickable((By.XPATH, # <input type="file"> below, and clicking it triggers the OS-native
"//button[.//span[contains(text(),'Add a file')]] | " # file picker, which WebDriver cannot control. Send the path
"//button[contains(normalize-space(text()),'Add a file')] | " # directly to the input instead (chromedriver allows send_keys on
"//*[contains(text(),'Add a file') and (@role='button' or self::label)]" # file inputs even while display:none).
)) file_input = WebDriverWait(self.driver, 10).until(
)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", add_file_btn)
ActionChains(self.driver).move_to_element(add_file_btn).click().perform()
time.sleep(1)
file_input = WebDriverWait(self.driver, 8).until(
EC.presence_of_element_located((By.XPATH, "//input[@type='file']")) EC.presence_of_element_located((By.XPATH, "//input[@type='file']"))
) )
self.driver.execute_script("arguments[0].style.display='block';", file_input)
file_input.send_keys(abs_path) file_input.send_keys(abs_path)
time.sleep(1.5) time.sleep(1.5)
print(f"[TuftsSCO Claim step5] Attached: {os.path.basename(abs_path)}") print(f"[TuftsSCO Claim step5] Attached: {os.path.basename(abs_path)}")
attached += 1 attached += 1
except Exception as e: except Exception as e:
print(f"[TuftsSCO Claim step5] Could not attach {abs_path}: {e}") print(f"[TuftsSCO Claim step5] Could not attach {abs_path}: {type(e).__name__}: {e}")
print(f"[TuftsSCO Claim step5] Attached {attached}/{len(self.claimFiles)} file(s)") print(f"[TuftsSCO Claim step5] Attached {attached}/{len(self.claimFiles)} file(s)")
return "SUCCESS" return "SUCCESS"
@@ -596,6 +596,36 @@ class AutomationTuftsSCOPreAuth:
except Exception as e: except Exception as e:
print(f"[TuftsSCO PreAuth step4] Warning: could not fill {label}: {e}") print(f"[TuftsSCO PreAuth step4] Warning: could not fill {label}: {e}")
def _fill_tooth_field(self, idx, tooth, context=""):
"""Fill the Tooth input for row idx, verifying the DOM value against what was typed
and retrying if it didn't stick. Entering the same procedure code on multiple rows
(e.g. one code across several teeth) triggers a duplicate-code warning banner that can
re-render the form and silently reset earlier rows' Tooth values — callers should re-run
this for every row after the whole form is filled, not just once at type-time."""
tooth = str(tooth).strip()
actual = None
for attempt in range(3):
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']")
if idx >= len(tooth_inputs):
return None
tooth_inp = tooth_inputs[idx]
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_inp)
self.driver.execute_script("arguments[0].focus();", tooth_inp)
time.sleep(0.2)
tooth_inp.send_keys(Keys.CONTROL + "a")
tooth_inp.send_keys(Keys.DELETE)
tooth_inp.send_keys(tooth)
time.sleep(0.3)
actual = (tooth_inp.get_attribute("value") or "").strip()
if actual == tooth:
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]{context}: confirmed '{tooth}'")
return actual
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]{context}: attempt {attempt + 1} — "
f"expected '{tooth}', field shows {actual!r}, retrying")
print(f"[TuftsSCO PreAuth step4] WARNING: tooth[{idx}]{context} did not stick — "
f"expected '{tooth}', field shows {actual!r} after 3 attempts")
return actual
def _fill_text_input(self, inp, value, label="field"): def _fill_text_input(self, inp, value, label="field"):
try: try:
inp.click() inp.click()
@@ -655,17 +685,8 @@ class AutomationTuftsSCOPreAuth:
if tooth: if tooth:
try: try:
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']") self._fill_tooth_field(idx, tooth)
if idx < len(tooth_inputs): time.sleep(0.1)
tooth_inp = tooth_inputs[idx]
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", tooth_inp)
self.driver.execute_script("arguments[0].focus();", tooth_inp)
time.sleep(0.2)
tooth_inp.send_keys(Keys.CONTROL + "a")
tooth_inp.send_keys(Keys.DELETE)
tooth_inp.send_keys(str(tooth))
print(f"[TuftsSCO PreAuth step4] tooth[{idx}]: typed '{tooth}'")
time.sleep(0.3)
except Exception as e: except Exception as e:
print(f"[TuftsSCO PreAuth step4] Could not fill tooth: {e}") print(f"[TuftsSCO PreAuth step4] Could not fill tooth: {e}")
@@ -714,6 +735,24 @@ class AutomationTuftsSCOPreAuth:
except Exception as e: except Exception as e:
print(f"[TuftsSCO PreAuth step4] Could not fill billed amount: {e}") print(f"[TuftsSCO PreAuth step4] Could not fill billed amount: {e}")
# Adding a row with a procedure code already used by an earlier row triggers a
# duplicate-code warning banner that re-renders the form — this can silently reset
# a previously-confirmed Tooth value on an earlier row. Re-check every row now that
# no more rows will be added (no more duplicate-code banners can fire after this).
print("[TuftsSCO PreAuth step4] Final tooth verification pass...")
for idx, line in enumerate(active_lines):
tooth = str(line.get("toothNumber") or line.get("tooth") or "").strip()
if not tooth:
continue
tooth_inputs = self.driver.find_elements(By.XPATH, "//input[@aria-label='Tooth']")
if idx >= len(tooth_inputs):
continue
actual = (tooth_inputs[idx].get_attribute("value") or "").strip()
if actual == tooth:
continue
print(f"[TuftsSCO PreAuth step4] Final check: tooth[{idx}] regressed to {actual!r}, re-filling '{tooth}'")
self._fill_tooth_field(idx, tooth, context=" (final pass)")
print("[TuftsSCO PreAuth step4] Done") print("[TuftsSCO PreAuth step4] Done")
return "SUCCESS" return "SUCCESS"
@@ -777,27 +816,56 @@ class AutomationTuftsSCOPreAuth:
# ── Step 6: Click "Next step" ────────────────────────────────────────────── # ── Step 6: Click "Next step" ──────────────────────────────────────────────
def step6_click_next(self): def step6_click_next(self):
"""Click the 'Next step' button.""" """Click the 'Next step' button and verify the wizard actually advanced to the
acknowledgement page — the URL doesn't change (SPA), so a click that's silently
ignored (e.g. button still disabled from a validation error) would otherwise look
identical to a successful one in the logs."""
try: try:
print(f"[TuftsSCO PreAuth step6] Current URL: {self.driver.current_url}") print(f"[TuftsSCO PreAuth step6] Current URL: {self.driver.current_url}")
btn = WebDriverWait(self.driver, 15).until(
EC.element_to_be_clickable((By.XPATH, advanced = False
"//button[@data-testid='next-step-btn'] | " for attempt in range(3):
"//button[@aria-label='Next step']" btn = WebDriverWait(self.driver, 15).until(
)) EC.element_to_be_clickable((By.XPATH,
) "//button[@data-testid='next-step-btn'] | "
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn) "//button[@aria-label='Next step']"
time.sleep(0.5) ))
self.driver.execute_script(""" )
var el = arguments[0]; disabled = (btn.get_attribute("disabled") is not None) or \
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); ((btn.get_attribute("aria-disabled") or "").lower() == "true")
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); if disabled:
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); print(f"[TuftsSCO PreAuth step6] 'Next step' is disabled (attempt {attempt + 1}) — "
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); f"a form field may still be invalid/empty; waiting before retry")
""", btn) time.sleep(1.5)
print("[TuftsSCO PreAuth step6] Clicked 'Next step'") continue
time.sleep(2)
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn)
time.sleep(0.5)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", btn)
print(f"[TuftsSCO PreAuth step6] Clicked 'Next step' (attempt {attempt + 1})")
try:
WebDriverWait(self.driver, 6).until(
EC.presence_of_element_located((By.XPATH,
"//label[contains(.,'submitting this')] | "
"//*[contains(@aria-label,'submitting this')]"
))
)
advanced = True
print("[TuftsSCO PreAuth step6] Confirmed wizard advanced to the acknowledgement step")
break
except TimeoutException:
print(f"[TuftsSCO PreAuth step6] Page did not advance after click (attempt {attempt + 1}), retrying")
print(f"[TuftsSCO PreAuth step6] URL after Next: {self.driver.current_url}") print(f"[TuftsSCO PreAuth step6] URL after Next: {self.driver.current_url}")
if not advanced:
return "ERROR: step6 failed: 'Next step' click did not advance the wizard after 3 attempts"
return "SUCCESS" return "SUCCESS"
except Exception as e: except Exception as e:
print(f"[TuftsSCO PreAuth step6] Exception: {e}") print(f"[TuftsSCO PreAuth step6] Exception: {e}")
@@ -805,30 +873,65 @@ class AutomationTuftsSCOPreAuth:
# ── Step 7: Acknowledge + submit ──────────────────────────────────────────── # ── Step 7: Acknowledge + submit ────────────────────────────────────────────
@staticmethod
def _is_checkbox_checked(el):
try:
if el.is_selected():
return True
except Exception:
pass
state = (el.get_attribute("aria-checked") or el.get_attribute("checked") or "").lower()
return state in ("true", "checked")
def step7_submit_preauth(self): def step7_submit_preauth(self):
"""On the pre-auth summary page, tick the acknowledgement checkbox then submit.""" """On the pre-auth summary page, tick the acknowledgement checkbox then submit."""
try: try:
print(f"[TuftsSCO PreAuth step7] Current URL: {self.driver.current_url}") print(f"[TuftsSCO PreAuth step7] Current URL: {self.driver.current_url}")
checkbox = WebDriverWait(self.driver, 15).until( # A bare "//input[@type='checkbox']" would match the FIRST checkbox in the DOM,
EC.presence_of_element_located((By.XPATH, # which may not be the acknowledgement box (e.g. a row-selection checkbox in the
"//input[@type='checkbox'] | " # service-line table). Try specific, label-anchored selectors before falling back.
"//*[@role='checkbox'] | " checkbox = None
"//label[contains(.,'submitting this')]//input | " for xpath in [
"//*[contains(@aria-label,'submitting this')]" "//label[contains(.,'submitting this')]//input[@type='checkbox']",
)) "//*[contains(@aria-label,'submitting this')]",
) "//label[contains(translate(., 'ACKNOWLEDGE', 'acknowledge'),'acknowledge')]//input[@type='checkbox']",
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox) "//*[contains(translate(@aria-label, 'ACKNOWLEDGE', 'acknowledge'),'acknowledge')]",
time.sleep(0.3) "//input[@type='checkbox']",
self.driver.execute_script(""" "//*[@role='checkbox']",
var el = arguments[0]; ]:
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true})); try:
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true})); checkbox = WebDriverWait(self.driver, 5).until(
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true})); EC.presence_of_element_located((By.XPATH, xpath))
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true})); )
""", checkbox) print(f"[TuftsSCO PreAuth step7] Found acknowledgement checkbox via: {xpath}")
print("[TuftsSCO PreAuth step7] Checked acknowledgement checkbox") break
time.sleep(0.5) except Exception:
continue
if checkbox is None:
return "ERROR: step7 failed: could not find acknowledgement checkbox"
for attempt in range(3):
if self._is_checkbox_checked(checkbox):
break
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox)
time.sleep(0.3)
self.driver.execute_script("""
var el = arguments[0];
el.dispatchEvent(new PointerEvent('pointerover', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerdown', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new PointerEvent('pointerup', {bubbles:true, cancelable:true, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
""", checkbox)
time.sleep(0.5)
if attempt > 0:
print(f"[TuftsSCO PreAuth step7] Acknowledgement checkbox retry {attempt + 1}")
if self._is_checkbox_checked(checkbox):
print("[TuftsSCO PreAuth step7] Confirmed acknowledgement checkbox is checked")
else:
print("[TuftsSCO PreAuth step7] WARNING: acknowledgement checkbox did not register as checked after 3 attempts")
all_btns = self.driver.find_elements(By.XPATH, "//button") all_btns = self.driver.find_elements(By.XPATH, "//button")
print(f"[TuftsSCO PreAuth step7] Buttons: {[b.get_attribute('aria-label') or b.text[:40] for b in all_btns]}") print(f"[TuftsSCO PreAuth step7] Buttons: {[b.get_attribute('aria-label') or b.text[:40] for b in all_btns]}")
@@ -1096,11 +1096,16 @@ class AutomationUnitedDHClaimSubmit:
print(f"[UnitedDH Claim] step7: Could not click caret (section may already be open): {e}") print(f"[UnitedDH Claim] step7: Could not click caret (section may already be open): {e}")
attached = 0 attached = 0
seen_paths = set()
for cf in self.claimFiles: for cf in self.claimFiles:
relative_path = cf.get("filePath") or "" relative_path = cf.get("filePath") or ""
if not relative_path: if not relative_path:
print(f"[UnitedDH Claim] step7: Skipping file with no filePath: {cf}") print(f"[UnitedDH Claim] step7: Skipping file with no filePath: {cf}")
continue continue
if relative_path in seen_paths:
print(f"[UnitedDH Claim] step7: Skipping duplicate file: {relative_path}")
continue
seen_paths.add(relative_path)
abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/"))) abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/")))
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
@@ -1113,6 +1118,10 @@ class AutomationUnitedDHClaimSubmit:
EC.presence_of_element_located((By.XPATH, "//input[@type='file']")) EC.presence_of_element_located((By.XPATH, "//input[@type='file']"))
) )
self.driver.execute_script("arguments[0].removeAttribute('class');", file_input) self.driver.execute_script("arguments[0].removeAttribute('class');", file_input)
# The input is a multi-file input and chromedriver APPENDS on send_keys,
# so a leftover selection from the previous iteration re-uploads the
# earlier file (2 attachments became 3). Clear it first.
self.driver.execute_script("arguments[0].value = '';", file_input)
file_input.send_keys(abs_path) file_input.send_keys(abs_path)
WebDriverWait(self.driver, 60).until( WebDriverWait(self.driver, 60).until(
EC.element_to_be_clickable((By.XPATH, EC.element_to_be_clickable((By.XPATH,
@@ -1035,11 +1035,16 @@ class AutomationUnitedDHPreAuth:
print(f"[UnitedDH PreAuth] step7: Could not click caret (section may already be open): {e}") print(f"[UnitedDH PreAuth] step7: Could not click caret (section may already be open): {e}")
attached = 0 attached = 0
seen_paths = set()
for cf in self.claimFiles: for cf in self.claimFiles:
relative_path = cf.get("filePath") or "" relative_path = cf.get("filePath") or ""
if not relative_path: if not relative_path:
print(f"[UnitedDH PreAuth] step7: Skipping file with no filePath: {cf}") print(f"[UnitedDH PreAuth] step7: Skipping file with no filePath: {cf}")
continue continue
if relative_path in seen_paths:
print(f"[UnitedDH PreAuth] step7: Skipping duplicate file: {relative_path}")
continue
seen_paths.add(relative_path)
abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/"))) abs_path = os.path.normpath(os.path.join(_BACKEND_CWD, relative_path.lstrip("/")))
if not os.path.isfile(abs_path): if not os.path.isfile(abs_path):
@@ -1052,6 +1057,10 @@ class AutomationUnitedDHPreAuth:
EC.presence_of_element_located((By.XPATH, "//input[@type='file']")) EC.presence_of_element_located((By.XPATH, "//input[@type='file']"))
) )
self.driver.execute_script("arguments[0].removeAttribute('class');", file_input) self.driver.execute_script("arguments[0].removeAttribute('class');", file_input)
# The input is a multi-file input and chromedriver APPENDS on send_keys,
# so a leftover selection from the previous iteration re-uploads the
# earlier file (2 attachments became 3). Clear it first.
self.driver.execute_script("arguments[0].value = '';", file_input)
file_input.send_keys(abs_path) file_input.send_keys(abs_path)
WebDriverWait(self.driver, 60).until( WebDriverWait(self.driver, 60).until(
EC.element_to_be_clickable((By.XPATH, EC.element_to_be_clickable((By.XPATH,
@@ -694,7 +694,7 @@ class AutomationMassHealthClaimsLogin:
print("DEBUG: Clicking plus button to add new service line") print("DEBUG: Clicking plus button to add new service line")
# Step 1: Wait a moment to ensure the previous service line is fully processed # Step 1: Wait a moment to ensure the previous service line is fully processed
time.sleep(3) time.sleep(1)
# Step 2: Try multiple approaches to find the plus button # Step 2: Try multiple approaches to find the plus button
plus_button = None plus_button = None
@@ -768,11 +768,11 @@ class AutomationMassHealthClaimsLogin:
print(f"DEBUG: JavaScript approach failed: {e}") print(f"DEBUG: JavaScript approach failed: {e}")
raise Exception("Could not find plus button with any method") raise Exception("Could not find plus button with any method")
time.sleep(1) time.sleep(0.5)
# Step 3: Scroll into view # Step 3: Scroll into view
self.driver.execute_script("arguments[0].scrollIntoView(true);", plus_button) self.driver.execute_script("arguments[0].scrollIntoView(true);", plus_button)
time.sleep(1) time.sleep(0.5)
# Step 4: Check if button is enabled # Step 4: Check if button is enabled
is_disabled = plus_button.get_attribute("disabled") is_disabled = plus_button.get_attribute("disabled")
@@ -804,7 +804,7 @@ class AutomationMassHealthClaimsLogin:
actions = ActionChains(self.driver) actions = ActionChains(self.driver)
actions.move_to_element(plus_button).click().perform() actions.move_to_element(plus_button).click().perform()
time.sleep(2) # Wait for new row to be created time.sleep(1) # Wait for new row to be created
print("DEBUG: Successfully clicked plus button") print("DEBUG: Successfully clicked plus button")
return "Success" return "Success"
+59
View File
@@ -0,0 +1,59 @@
# Dental Agent (Windows)
`agent.py` runs on a staff front-desk Windows PC. It connects to the app's server over the
`/agent` socket.io namespace and executes low-level commands — screenshot, click,
double-click, type, key press — sent by the server. It has no knowledge of dental software
or workflows; the app's AI reads the screenshots this agent sends back and decides what to
click or type next.
Not wired into the repo's root `npm install` on purpose — it targets Windows (`pyautogui`,
`tkinter`) and doesn't need to install on every contributor's machine.
## Command protocol
Sent by the server, over the `/agent` namespace, as socket.io events with an ack callback:
| Event | Payload | Ack response |
|---------------------|-----------------------|--------------------|
| `cmd:screenshot` | `{}` | `{ image: <base64 PNG> }` |
| `cmd:click` | `{ x, y }` | `{ ok: true }` |
| `cmd:double_click` | `{ x, y }` | `{ ok: true }` |
| `cmd:double_click_current` | `{}` | `{ ok: true }` |
| `cmd:type` | `{ text }` | `{ ok: true }` |
| `cmd:key` | `{ key }` | `{ ok: true }` |
Auth on connect: `{ auth: { token } }`, where `token` must match the server's
`WINDOWS_AGENT_TOKEN`. The token is a constant baked into `agent.py` (`AGENT_TOKEN` near the
top) — set it before building for a real office, staff never see or type it. The server tells
front-desk PCs apart by connection IP address (shown on the Type Agent page), not anything
the agent sends.
## Local dev / testing
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python agent.py
```
On first run it asks only for the Server URL, then saves it to `agent_config.json` next to
the script so it reconnects automatically after that. After that it has no window — just a
system tray icon (green = Connected, gray = Connecting, red = Disconnected). Click it (or
right-click for the same menu) for status, the server URL, "Settings..." (reopens the setup
window pre-filled with the current URL — close without submitting to leave it unchanged), and
Quit.
`apps/Backend/scripts/fake-windows-agent.js` is a Node stand-in for this agent — useful for
testing the server-side bridge without a Windows PC or a real screen to click on.
## Building DentalAgent.exe
Must be run **on Windows** (PyInstaller doesn't cross-compile):
```bat
py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\pyinstaller --onefile --windowed --name DentalAgent agent.py
```
Output: `dist\DentalAgent.exe`. Staff download and run it once — no other setup.
+225
View File
@@ -0,0 +1,225 @@
"""
DentalAgent — runs on a staff front-desk Windows PC. Connects to the app's server over
the /agent socket.io namespace and executes low-level mouse/keyboard/screenshot commands
sent by the server. It has no knowledge of dental software, patients, or workflows — the
app's AI decides what to click/type by reading the screenshots this agent sends back.
Configuration (via a small popup on first run, saved to agent_config.json next to the exe):
- Server URL (e.g. http://192.168.0.240:5000) — the only thing staff need to enter.
The server identifies which PC is which by connection IP address (shown on the Type Agent
page), so there's no Office ID to type. The AGENT_TOKEN below authenticates the exe itself
to the server — set it to match the server's WINDOWS_AGENT_TOKEN before distributing this
to real front-desk PCs; it's baked into the build, not something staff ever see or type.
After connecting it has no visible window — just a system tray icon showing
Connected/Connecting/Disconnected. Clicking the icon reopens the same setup window
(pre-filled with the current URL) without restarting the process — a self-relaunch turned
out to be unreliable with PyInstaller --onefile builds on Windows (the freshly spawned copy
can crash trying to reuse the outgoing process's temp extraction folder), so instead a single
hidden Tk window is kept alive for the whole run and just shown/hidden as needed.
"""
AGENT_TOKEN = "dev-windows-agent-token"
import base64
import io
import json
import os
import sys
import threading
import tkinter as tk
from tkinter import messagebox
import pyautogui
import pystray
import socketio
from PIL import Image, ImageDraw, ImageGrab
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent_config.json")
pyautogui.FAILSAFE = True # moving mouse to a screen corner aborts an in-progress action
def load_config():
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r") as f:
return json.load(f)
return {}
def save_config(config):
with open(CONFIG_PATH, "w") as f:
json.dump(config, f)
def make_status_icon(color):
image = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
draw.ellipse((8, 8, 56, 56), fill=color)
return image
ICON_CONNECTED = make_status_icon("#22c55e")
ICON_DISCONNECTED = make_status_icon("#ef4444")
ICON_CONNECTING = make_status_icon("#9ca3af")
ICON_NOT_CONFIGURED = make_status_icon("#9ca3af")
class DentalAgent:
def __init__(self):
self.server_url = None
self.status = "Not configured"
self.tray_icon = None
self.sio = socketio.Client(reconnection=True, reconnection_attempts=0)
self._register_handlers()
def _set_status(self, status, icon_image):
self.status = status
if self.tray_icon:
self.tray_icon.icon = icon_image
self.tray_icon.title = f"Dental Agent - {status}"
self.tray_icon.update_menu()
def _register_handlers(self):
sio = self.sio
@sio.event(namespace="/agent")
def connect():
print(f"Connected to {self.server_url}")
self._set_status("Connected", ICON_CONNECTED)
@sio.event(namespace="/agent")
def connect_error(data):
print(f"Connection failed: {data}")
self._set_status("Connection failed", ICON_DISCONNECTED)
@sio.event(namespace="/agent")
def disconnect():
print("Disconnected")
self._set_status("Disconnected", ICON_DISCONNECTED)
@sio.on("cmd:screenshot", namespace="/agent")
def on_screenshot(_data=None):
image = ImageGrab.grab()
buf = io.BytesIO()
image.save(buf, format="PNG")
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
return {"image": encoded}
@sio.on("cmd:move", namespace="/agent")
def on_move(data):
# Moves the cursor without clicking — used to double-check a location (via a
# follow-up cropped screenshot) before committing to a click.
pyautogui.moveTo(data["x"], data["y"])
return {"ok": True}
@sio.on("cmd:click", namespace="/agent")
def on_click(data):
pyautogui.click(data["x"], data["y"])
return {"ok": True}
@sio.on("cmd:double_click", namespace="/agent")
def on_double_click(data):
pyautogui.doubleClick(data["x"], data["y"])
return {"ok": True}
@sio.on("cmd:double_click_current", namespace="/agent")
def on_double_click_current(_data=None):
# No x/y — clicks wherever the mouse already is. Lets staff pre-position the
# cursor over the target (e.g. an open time slot) before minimizing the browser,
# so this step doesn't need a screenshot/vision lookup at all.
pyautogui.doubleClick()
return {"ok": True}
@sio.on("cmd:type", namespace="/agent")
def on_type(data):
pyautogui.write(data["text"], interval=0.02)
return {"ok": True}
@sio.on("cmd:key", namespace="/agent")
def on_key(data):
pyautogui.press(data["key"])
return {"ok": True}
def connect_to(self, server_url):
"""(Re)connects to server_url, reusing the same socketio.Client for the process's
whole lifetime — safe to call again later with a new URL to switch servers."""
self.server_url = server_url
self._set_status("Connecting...", ICON_CONNECTING)
def _run():
try:
if self.sio.connected:
self.sio.disconnect()
self.sio.connect(
server_url,
namespaces=["/agent"],
auth={"token": AGENT_TOKEN},
wait_timeout=10,
)
self.sio.wait()
except Exception as exc:
print(f"Agent stopped: {exc}")
self._set_status("Connection failed", ICON_DISCONNECTED)
threading.Thread(target=_run, daemon=True).start()
def main():
config = load_config()
agent = DentalAgent()
# --- Settings window: built once, shown/hidden for the process's whole lifetime ---
root = tk.Tk()
root.title("Dental Agent Setup")
root.resizable(False, False)
root.withdraw()
root.protocol("WM_DELETE_WINDOW", root.withdraw) # closing the window just hides it
tk.Label(root, text="Server URL").pack(pady=(15, 0))
server_entry = tk.Entry(root, width=40)
server_entry.pack(padx=15)
def on_connect():
server_url = server_entry.get().strip()
if not server_url:
messagebox.showerror("Dental Agent", "Server URL is required.")
return
save_config({"server_url": server_url})
root.withdraw()
agent.connect_to(server_url)
tk.Button(root, text="Connect", command=on_connect).pack(pady=15)
root.bind("<Return>", lambda _event: on_connect())
def show_settings():
server_entry.delete(0, tk.END)
server_entry.insert(0, agent.server_url or config.get("server_url", "http://"))
root.deiconify()
root.eval("tk::PlaceWindow . center")
root.lift()
root.focus_force()
# --- Tray icon: runs on its own thread so Tk can own the main thread/event loop ---
menu = pystray.Menu(
pystray.MenuItem(lambda _item: f"Status: {agent.status}", None, enabled=False),
pystray.MenuItem(lambda _item: f"Server: {agent.server_url or '(not set)'}", None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Settings...", lambda: root.after(0, show_settings), default=True),
pystray.MenuItem("Quit", lambda icon: (icon.stop(), root.after(0, root.quit))),
)
icon = pystray.Icon("DentalAgent", ICON_NOT_CONFIGURED, "Dental Agent - Not configured", menu)
agent.tray_icon = icon
threading.Thread(target=icon.run, daemon=True).start()
if "server_url" in config:
agent.connect_to(config["server_url"])
else:
show_settings() # first run — nothing saved yet, ask right away
root.mainloop()
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
{
"name": "windowsagent",
"private": true
}
+7
View File
@@ -0,0 +1,7 @@
python-socketio[client]==5.13.0
python-engineio==4.11.2
websocket-client==1.8.0
pyautogui==0.9.54
Pillow==11.1.0
pystray==0.19.5
pyinstaller==6.11.1
+4 -4
View File
@@ -17,7 +17,7 @@ server {
# API requests → backend (Authorization header must be explicit or it gets stripped) # API requests → backend (Authorization header must be explicit or it gets stripped)
location /api/ { location /api/ {
proxy_pass http://localhost:5000; proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -27,7 +27,7 @@ server {
# Socket.IO → backend (WebSocket upgrade) # Socket.IO → backend (WebSocket upgrade)
location /socket.io/ { location /socket.io/ {
proxy_pass http://localhost:5000; proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -37,7 +37,7 @@ server {
# Everything else → Vite dev server # Everything else → Vite dev server
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -57,7 +57,7 @@ server {
client_max_body_size 50m; client_max_body_size 50m;
location /api/twilio/ { location /api/twilio/ {
proxy_pass http://localhost:5000; proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+582 -6
View File
@@ -57,6 +57,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"sharp": "^0.35.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"twilio": "^6.0.0", "twilio": "^6.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",
@@ -196,22 +197,22 @@
} }
}, },
"apps/PatientDataExtractorService": { "apps/PatientDataExtractorService": {
"name": "patientdataextractorservice", "name": "patientdataextractorservice"
"hasInstallScript": true
}, },
"apps/PaymentOCRService": { "apps/PaymentOCRService": {
"name": "paymentocrservice", "name": "paymentocrservice"
"hasInstallScript": true
}, },
"apps/SeleniumService": { "apps/SeleniumService": {
"name": "seleniumservice", "name": "seleniumservice"
"hasInstallScript": true
}, },
"apps/SeleniumServiceold": { "apps/SeleniumServiceold": {
"name": "seleniumserviceold", "name": "seleniumserviceold",
"extraneous": true, "extraneous": true,
"hasInstallScript": true "hasInstallScript": true
}, },
"apps/WindowsAgent": {
"name": "windowsagent"
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -653,6 +654,16 @@
"@electric-sql/pglite": "0.3.15" "@electric-sql/pglite": "0.3.15"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1346,6 +1357,506 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -12663,6 +13174,67 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -14913,6 +15485,10 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/windowsagent": {
"resolved": "apps/WindowsAgent",
"link": true
},
"node_modules/wmf": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+2 -1
View File
@@ -13,7 +13,8 @@
"db:seed": "npx ts-node packages/db/prisma/seed.ts", "db:seed": "npx ts-node packages/db/prisma/seed.ts",
"db:studio": "npx prisma studio --config=packages/db/prisma/prisma.config.ts", "db:studio": "npx prisma studio --config=packages/db/prisma/prisma.config.ts",
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env", "setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall && bash scripts/install-rclone.sh" "setup:python": "npm run setup --workspace=apps/PatientDataExtractorService && npm run setup --workspace=apps/PaymentOCRService && npm run setup --workspace=apps/SeleniumService",
"postinstall": "bash scripts/install-rclone.sh"
}, },
"prisma": { "prisma": {
"seed": "ts-node packages/db/prisma/seed.ts" "seed": "ts-node packages/db/prisma/seed.ts"
File diff suppressed because one or more lines are too long
@@ -152,6 +152,8 @@ exports.Prisma.PatientScalarFieldEnum = {
medicalConditions: 'medicalConditions', medicalConditions: 'medicalConditions',
preferredLanguage: 'preferredLanguage', preferredLanguage: 'preferredLanguage',
status: 'status', status: 'status',
remainingBenefit: 'remainingBenefit',
thirdPartyInsurance: 'thirdPartyInsurance',
userId: 'userId', userId: 'userId',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
@@ -162,6 +164,7 @@ exports.Prisma.AppointmentScalarFieldEnum = {
patientId: 'patientId', patientId: 'patientId',
userId: 'userId', userId: 'userId',
staffId: 'staffId', staffId: 'staffId',
npiProviderId: 'npiProviderId',
title: 'title', title: 'title',
date: 'date', date: 'date',
startTime: 'startTime', startTime: 'startTime',
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "prisma-client-1397903f012032ff276557e7a077db07df8a7cae6f68fef0b7224d110a42b0e3", "name": "prisma-client-11a124b84a93d3e63379c8a6769ab83d8729151f8cca035589101564f7ede8ed",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "default.js", "browser": "default.js",
+42 -37
View File
@@ -55,37 +55,39 @@ model User {
} }
model Patient { model Patient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
firstName String firstName String
lastName String lastName String
dateOfBirth DateTime? @db.Date dateOfBirth DateTime? @db.Date
gender String gender String
phone String phone String
email String? email String?
address String? address String?
city String? city String?
zipCode String? zipCode String?
insuranceProvider String? insuranceProvider String?
insuranceId String? insuranceId String?
groupNumber String? groupNumber String?
policyHolder String? policyHolder String?
allergies String? allergies String?
medicalConditions String? medicalConditions String?
preferredLanguage String? @default("English") preferredLanguage String? @default("English")
status PatientStatus @default(UNKNOWN) status PatientStatus @default(UNKNOWN)
userId Int remainingBenefit String?
createdAt DateTime @default(now()) thirdPartyInsurance String?
updatedAt DateTime @updatedAt userId Int
user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now())
appointments Appointment[] updatedAt DateTime @updatedAt
procedures AppointmentProcedure[] user User @relation(fields: [userId], references: [id])
claims Claim[] appointments Appointment[]
groups PdfGroup[] procedures AppointmentProcedure[]
payment Payment[] claims Claim[]
communications Communication[] groups PdfGroup[]
documents PatientDocument[] payment Payment[]
conversation PatientConversation? communications Communication[]
cloudFolders CloudFolder[] @relation("PatientCloudFolder") documents PatientDocument[]
conversation PatientConversation?
cloudFolders CloudFolder[] @relation("PatientCloudFolder")
@@index([insuranceId]) @@index([insuranceId])
@@index([createdAt]) @@index([createdAt])
@@ -103,6 +105,7 @@ model Appointment {
patientId Int patientId Int
userId Int userId Int
staffId Int staffId Int
npiProviderId Int?
title String title String
date DateTime @db.Date date DateTime @db.Date
startTime String // Store time as "hh:mm" startTime String // Store time as "hh:mm"
@@ -117,12 +120,13 @@ model Appointment {
eligibilityStatus PatientStatus @default(UNKNOWN) eligibilityStatus PatientStatus @default(UNKNOWN)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
staff Staff? @relation(fields: [staffId], references: [id]) staff Staff? @relation(fields: [staffId], references: [id])
procedures AppointmentProcedure[] npiProvider NpiProvider? @relation(fields: [npiProviderId], references: [id])
claims Claim[] procedures AppointmentProcedure[]
files AppointmentFile[] claims Claim[]
files AppointmentFile[]
@@index([patientId]) @@index([patientId])
@@index([date]) @@index([date])
@@ -166,6 +170,7 @@ model NpiProvider {
payments Payment[] payments Payment[]
commissionBatches CommissionBatch[] commissionBatches CommissionBatch[]
appointmentProcedures AppointmentProcedure[] appointmentProcedures AppointmentProcedure[]
appointments Appointment[]
@@unique([userId, npiNumber]) @@unique([userId, npiNumber])
@@index([userId]) @@index([userId])
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "npiProviderId" INTEGER;
-- AddForeignKey
ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `doctorName` on the `lab_rx_template` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Patient" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "autoMhCheckDayOfWeek" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "autoMhCheckEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoMhCheckHour" INTEGER NOT NULL DEFAULT 13;
-- AlterTable
ALTER TABLE "lab_rx_template" DROP COLUMN "doctorName";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Patient" ADD COLUMN "remainingBenefit" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Patient" ADD COLUMN "thirdPartyInsurance" TEXT;
+11 -6
View File
@@ -73,6 +73,8 @@ model Patient {
medicalConditions String? medicalConditions String?
preferredLanguage String? @default("English") preferredLanguage String? @default("English")
status PatientStatus @default(UNKNOWN) status PatientStatus @default(UNKNOWN)
remainingBenefit String?
thirdPartyInsurance String?
userId Int userId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -103,6 +105,7 @@ model Appointment {
patientId Int patientId Int
userId Int userId Int
staffId Int staffId Int
npiProviderId Int?
title String title String
date DateTime @db.Date date DateTime @db.Date
startTime String // Store time as "hh:mm" startTime String // Store time as "hh:mm"
@@ -117,12 +120,13 @@ model Appointment {
eligibilityStatus PatientStatus @default(UNKNOWN) eligibilityStatus PatientStatus @default(UNKNOWN)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade) patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
staff Staff? @relation(fields: [staffId], references: [id]) staff Staff? @relation(fields: [staffId], references: [id])
procedures AppointmentProcedure[] npiProvider NpiProvider? @relation(fields: [npiProviderId], references: [id])
claims Claim[] procedures AppointmentProcedure[]
files AppointmentFile[] claims Claim[]
files AppointmentFile[]
@@index([patientId]) @@index([patientId])
@@index([date]) @@index([date])
@@ -166,6 +170,7 @@ model NpiProvider {
payments Payment[] payments Payment[]
commissionBatches CommissionBatch[] commissionBatches CommissionBatch[]
appointmentProcedures AppointmentProcedure[] appointmentProcedures AppointmentProcedure[]
appointments Appointment[]
@@unique([userId, npiNumber]) @@unique([userId, npiNumber])
@@index([userId]) @@index([userId])
@@ -1,8 +1,8 @@
{ {
"version": "1.0", "version": "1.0",
"generatorVersion": "1.0.0", "generatorVersion": "1.0.0",
"generatedAt": "2026-07-03T03:44:29.373Z", "generatedAt": "2026-09-03T01:38:13.906Z",
"outputPath": "/home/gg/Desktop/DentalManagementMH06/packages/db/shared", "outputPath": "/home/gg/Desktop/DentalManagementMH07/packages/db/shared",
"files": [ "files": [
"schemas/enums/TransactionIsolationLevel.schema.ts", "schemas/enums/TransactionIsolationLevel.schema.ts",
"schemas/enums/UserScalarFieldEnum.schema.ts", "schemas/enums/UserScalarFieldEnum.schema.ts",
@@ -564,8 +564,10 @@
"schemas/objects/StringNullableWithAggregatesFilter.schema.ts", "schemas/objects/StringNullableWithAggregatesFilter.schema.ts",
"schemas/objects/EnumPatientStatusWithAggregatesFilter.schema.ts", "schemas/objects/EnumPatientStatusWithAggregatesFilter.schema.ts",
"schemas/objects/DateTimeWithAggregatesFilter.schema.ts", "schemas/objects/DateTimeWithAggregatesFilter.schema.ts",
"schemas/objects/IntNullableFilter.schema.ts",
"schemas/objects/PatientScalarRelationFilter.schema.ts", "schemas/objects/PatientScalarRelationFilter.schema.ts",
"schemas/objects/StaffNullableScalarRelationFilter.schema.ts", "schemas/objects/StaffNullableScalarRelationFilter.schema.ts",
"schemas/objects/NpiProviderNullableScalarRelationFilter.schema.ts",
"schemas/objects/AppointmentFileListRelationFilter.schema.ts", "schemas/objects/AppointmentFileListRelationFilter.schema.ts",
"schemas/objects/AppointmentFileOrderByRelationAggregateInput.schema.ts", "schemas/objects/AppointmentFileOrderByRelationAggregateInput.schema.ts",
"schemas/objects/AppointmentCountOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentCountOrderByAggregateInput.schema.ts",
@@ -573,6 +575,7 @@
"schemas/objects/AppointmentMaxOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentMaxOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentMinOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentMinOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentSumOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentSumOrderByAggregateInput.schema.ts",
"schemas/objects/IntNullableWithAggregatesFilter.schema.ts",
"schemas/objects/AppointmentScalarRelationFilter.schema.ts", "schemas/objects/AppointmentScalarRelationFilter.schema.ts",
"schemas/objects/AppointmentFileCountOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentFileCountOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentFileAvgOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentFileAvgOrderByAggregateInput.schema.ts",
@@ -593,16 +596,13 @@
"schemas/objects/NpiProviderMaxOrderByAggregateInput.schema.ts", "schemas/objects/NpiProviderMaxOrderByAggregateInput.schema.ts",
"schemas/objects/NpiProviderMinOrderByAggregateInput.schema.ts", "schemas/objects/NpiProviderMinOrderByAggregateInput.schema.ts",
"schemas/objects/NpiProviderSumOrderByAggregateInput.schema.ts", "schemas/objects/NpiProviderSumOrderByAggregateInput.schema.ts",
"schemas/objects/IntNullableFilter.schema.ts",
"schemas/objects/DecimalNullableFilter.schema.ts", "schemas/objects/DecimalNullableFilter.schema.ts",
"schemas/objects/EnumProcedureSourceFilter.schema.ts", "schemas/objects/EnumProcedureSourceFilter.schema.ts",
"schemas/objects/NpiProviderNullableScalarRelationFilter.schema.ts",
"schemas/objects/AppointmentProcedureCountOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentProcedureCountOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentProcedureAvgOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentProcedureAvgOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentProcedureMaxOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentProcedureMaxOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentProcedureMinOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentProcedureMinOrderByAggregateInput.schema.ts",
"schemas/objects/AppointmentProcedureSumOrderByAggregateInput.schema.ts", "schemas/objects/AppointmentProcedureSumOrderByAggregateInput.schema.ts",
"schemas/objects/IntNullableWithAggregatesFilter.schema.ts",
"schemas/objects/DecimalNullableWithAggregatesFilter.schema.ts", "schemas/objects/DecimalNullableWithAggregatesFilter.schema.ts",
"schemas/objects/EnumProcedureSourceWithAggregatesFilter.schema.ts", "schemas/objects/EnumProcedureSourceWithAggregatesFilter.schema.ts",
"schemas/objects/EnumMissingTeethStatusFilter.schema.ts", "schemas/objects/EnumMissingTeethStatusFilter.schema.ts",
@@ -948,6 +948,7 @@
"schemas/objects/PatientCreateNestedOneWithoutAppointmentsInput.schema.ts", "schemas/objects/PatientCreateNestedOneWithoutAppointmentsInput.schema.ts",
"schemas/objects/UserCreateNestedOneWithoutAppointmentsInput.schema.ts", "schemas/objects/UserCreateNestedOneWithoutAppointmentsInput.schema.ts",
"schemas/objects/StaffCreateNestedOneWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffCreateNestedOneWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderCreateNestedOneWithoutAppointmentsInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateNestedManyWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureCreateNestedManyWithoutAppointmentInput.schema.ts",
"schemas/objects/ClaimCreateNestedManyWithoutAppointmentInput.schema.ts", "schemas/objects/ClaimCreateNestedManyWithoutAppointmentInput.schema.ts",
"schemas/objects/AppointmentFileCreateNestedManyWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentFileCreateNestedManyWithoutAppointmentInput.schema.ts",
@@ -957,9 +958,11 @@
"schemas/objects/PatientUpdateOneRequiredWithoutAppointmentsNestedInput.schema.ts", "schemas/objects/PatientUpdateOneRequiredWithoutAppointmentsNestedInput.schema.ts",
"schemas/objects/UserUpdateOneRequiredWithoutAppointmentsNestedInput.schema.ts", "schemas/objects/UserUpdateOneRequiredWithoutAppointmentsNestedInput.schema.ts",
"schemas/objects/StaffUpdateOneWithoutAppointmentsNestedInput.schema.ts", "schemas/objects/StaffUpdateOneWithoutAppointmentsNestedInput.schema.ts",
"schemas/objects/NpiProviderUpdateOneWithoutAppointmentsNestedInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateManyWithoutAppointmentNestedInput.schema.ts",
"schemas/objects/ClaimUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/ClaimUpdateManyWithoutAppointmentNestedInput.schema.ts",
"schemas/objects/AppointmentFileUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/AppointmentFileUpdateManyWithoutAppointmentNestedInput.schema.ts",
"schemas/objects/NullableIntFieldUpdateOperationsInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts",
"schemas/objects/ClaimUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/ClaimUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts",
"schemas/objects/AppointmentFileUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts", "schemas/objects/AppointmentFileUncheckedUpdateManyWithoutAppointmentNestedInput.schema.ts",
@@ -980,19 +983,23 @@
"schemas/objects/PaymentCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/PaymentCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/CommissionBatchCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/CommissionBatchCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/ClaimUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/ClaimUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/PaymentUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/PaymentUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/CommissionBatchUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/CommissionBatchUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUncheckedCreateNestedManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/UserUpdateOneRequiredWithoutNpiProvidersNestedInput.schema.ts", "schemas/objects/UserUpdateOneRequiredWithoutNpiProvidersNestedInput.schema.ts",
"schemas/objects/ClaimUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/ClaimUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/PaymentUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/PaymentUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/CommissionBatchUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/CommissionBatchUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/AppointmentUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/ClaimUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/ClaimUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/PaymentUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/PaymentUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/CommissionBatchUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/CommissionBatchUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/AppointmentUncheckedUpdateManyWithoutNpiProviderNestedInput.schema.ts",
"schemas/objects/AppointmentCreateNestedOneWithoutProceduresInput.schema.ts", "schemas/objects/AppointmentCreateNestedOneWithoutProceduresInput.schema.ts",
"schemas/objects/PatientCreateNestedOneWithoutProceduresInput.schema.ts", "schemas/objects/PatientCreateNestedOneWithoutProceduresInput.schema.ts",
"schemas/objects/NpiProviderCreateNestedOneWithoutAppointmentProceduresInput.schema.ts", "schemas/objects/NpiProviderCreateNestedOneWithoutAppointmentProceduresInput.schema.ts",
@@ -1001,7 +1008,6 @@
"schemas/objects/AppointmentUpdateOneRequiredWithoutProceduresNestedInput.schema.ts", "schemas/objects/AppointmentUpdateOneRequiredWithoutProceduresNestedInput.schema.ts",
"schemas/objects/PatientUpdateOneRequiredWithoutProceduresNestedInput.schema.ts", "schemas/objects/PatientUpdateOneRequiredWithoutProceduresNestedInput.schema.ts",
"schemas/objects/NpiProviderUpdateOneWithoutAppointmentProceduresNestedInput.schema.ts", "schemas/objects/NpiProviderUpdateOneWithoutAppointmentProceduresNestedInput.schema.ts",
"schemas/objects/NullableIntFieldUpdateOperationsInput.schema.ts",
"schemas/objects/PatientCreateNestedOneWithoutClaimsInput.schema.ts", "schemas/objects/PatientCreateNestedOneWithoutClaimsInput.schema.ts",
"schemas/objects/AppointmentCreateNestedOneWithoutClaimsInput.schema.ts", "schemas/objects/AppointmentCreateNestedOneWithoutClaimsInput.schema.ts",
"schemas/objects/UserCreateNestedOneWithoutClaimsInput.schema.ts", "schemas/objects/UserCreateNestedOneWithoutClaimsInput.schema.ts",
@@ -1165,10 +1171,10 @@
"schemas/objects/NestedStringNullableWithAggregatesFilter.schema.ts", "schemas/objects/NestedStringNullableWithAggregatesFilter.schema.ts",
"schemas/objects/NestedEnumPatientStatusWithAggregatesFilter.schema.ts", "schemas/objects/NestedEnumPatientStatusWithAggregatesFilter.schema.ts",
"schemas/objects/NestedDateTimeWithAggregatesFilter.schema.ts", "schemas/objects/NestedDateTimeWithAggregatesFilter.schema.ts",
"schemas/objects/NestedDecimalNullableFilter.schema.ts",
"schemas/objects/NestedEnumProcedureSourceFilter.schema.ts",
"schemas/objects/NestedIntNullableWithAggregatesFilter.schema.ts", "schemas/objects/NestedIntNullableWithAggregatesFilter.schema.ts",
"schemas/objects/NestedFloatNullableFilter.schema.ts", "schemas/objects/NestedFloatNullableFilter.schema.ts",
"schemas/objects/NestedDecimalNullableFilter.schema.ts",
"schemas/objects/NestedEnumProcedureSourceFilter.schema.ts",
"schemas/objects/NestedDecimalNullableWithAggregatesFilter.schema.ts", "schemas/objects/NestedDecimalNullableWithAggregatesFilter.schema.ts",
"schemas/objects/NestedEnumProcedureSourceWithAggregatesFilter.schema.ts", "schemas/objects/NestedEnumProcedureSourceWithAggregatesFilter.schema.ts",
"schemas/objects/NestedEnumMissingTeethStatusFilter.schema.ts", "schemas/objects/NestedEnumMissingTeethStatusFilter.schema.ts",
@@ -1459,6 +1465,9 @@
"schemas/objects/StaffCreateWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffCreateWithoutAppointmentsInput.schema.ts",
"schemas/objects/StaffUncheckedCreateWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffUncheckedCreateWithoutAppointmentsInput.schema.ts",
"schemas/objects/StaffCreateOrConnectWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffCreateOrConnectWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderCreateWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderUncheckedCreateWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderCreateOrConnectWithoutAppointmentsInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureCreateWithoutAppointmentInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedCreateWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedCreateWithoutAppointmentInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateOrConnectWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureCreateOrConnectWithoutAppointmentInput.schema.ts",
@@ -1483,6 +1492,10 @@
"schemas/objects/StaffUpdateToOneWithWhereWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffUpdateToOneWithWhereWithoutAppointmentsInput.schema.ts",
"schemas/objects/StaffUpdateWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffUpdateWithoutAppointmentsInput.schema.ts",
"schemas/objects/StaffUncheckedUpdateWithoutAppointmentsInput.schema.ts", "schemas/objects/StaffUncheckedUpdateWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderUpsertWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderUpdateToOneWithWhereWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderUpdateWithoutAppointmentsInput.schema.ts",
"schemas/objects/NpiProviderUncheckedUpdateWithoutAppointmentsInput.schema.ts",
"schemas/objects/AppointmentProcedureUpsertWithWhereUniqueWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureUpsertWithWhereUniqueWithoutAppointmentInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateWithWhereUniqueWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateWithWhereUniqueWithoutAppointmentInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateManyWithWhereWithoutAppointmentInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateManyWithWhereWithoutAppointmentInput.schema.ts",
@@ -1540,6 +1553,10 @@
"schemas/objects/AppointmentProcedureUncheckedCreateWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedCreateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateOrConnectWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureCreateOrConnectWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateManyNpiProviderInputEnvelope.schema.ts", "schemas/objects/AppointmentProcedureCreateManyNpiProviderInputEnvelope.schema.ts",
"schemas/objects/AppointmentCreateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUncheckedCreateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentCreateOrConnectWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentCreateManyNpiProviderInputEnvelope.schema.ts",
"schemas/objects/UserUpsertWithoutNpiProvidersInput.schema.ts", "schemas/objects/UserUpsertWithoutNpiProvidersInput.schema.ts",
"schemas/objects/UserUpdateToOneWithWhereWithoutNpiProvidersInput.schema.ts", "schemas/objects/UserUpdateToOneWithWhereWithoutNpiProvidersInput.schema.ts",
"schemas/objects/UserUpdateWithoutNpiProvidersInput.schema.ts", "schemas/objects/UserUpdateWithoutNpiProvidersInput.schema.ts",
@@ -1557,6 +1574,9 @@
"schemas/objects/AppointmentProcedureUpsertWithWhereUniqueWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUpsertWithWhereUniqueWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateWithWhereUniqueWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateWithWhereUniqueWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureUpdateManyWithWhereWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateManyWithWhereWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUpsertWithWhereUniqueWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUpdateWithWhereUniqueWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUpdateManyWithWhereWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentCreateWithoutProceduresInput.schema.ts", "schemas/objects/AppointmentCreateWithoutProceduresInput.schema.ts",
"schemas/objects/AppointmentUncheckedCreateWithoutProceduresInput.schema.ts", "schemas/objects/AppointmentUncheckedCreateWithoutProceduresInput.schema.ts",
"schemas/objects/AppointmentCreateOrConnectWithoutProceduresInput.schema.ts", "schemas/objects/AppointmentCreateOrConnectWithoutProceduresInput.schema.ts",
@@ -2093,6 +2113,7 @@
"schemas/objects/PaymentCreateManyNpiProviderInput.schema.ts", "schemas/objects/PaymentCreateManyNpiProviderInput.schema.ts",
"schemas/objects/CommissionBatchCreateManyNpiProviderInput.schema.ts", "schemas/objects/CommissionBatchCreateManyNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureCreateManyNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureCreateManyNpiProviderInput.schema.ts",
"schemas/objects/AppointmentCreateManyNpiProviderInput.schema.ts",
"schemas/objects/ClaimUpdateWithoutNpiProviderInput.schema.ts", "schemas/objects/ClaimUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/ClaimUncheckedUpdateWithoutNpiProviderInput.schema.ts", "schemas/objects/ClaimUncheckedUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/ClaimUncheckedUpdateManyWithoutNpiProviderInput.schema.ts", "schemas/objects/ClaimUncheckedUpdateManyWithoutNpiProviderInput.schema.ts",
@@ -2105,6 +2126,9 @@
"schemas/objects/AppointmentProcedureUpdateWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedUpdateWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutNpiProviderInput.schema.ts", "schemas/objects/AppointmentProcedureUncheckedUpdateManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUncheckedUpdateWithoutNpiProviderInput.schema.ts",
"schemas/objects/AppointmentUncheckedUpdateManyWithoutNpiProviderInput.schema.ts",
"schemas/objects/ServiceLineCreateManyClaimInput.schema.ts", "schemas/objects/ServiceLineCreateManyClaimInput.schema.ts",
"schemas/objects/ClaimFileCreateManyClaimInput.schema.ts", "schemas/objects/ClaimFileCreateManyClaimInput.schema.ts",
"schemas/objects/ServiceLineUpdateWithoutClaimInput.schema.ts", "schemas/objects/ServiceLineUpdateWithoutClaimInput.schema.ts",
@@ -2380,6 +2404,7 @@
"schemas/objects/NpiProviderCountOutputTypeCountPaymentsArgs.schema.ts", "schemas/objects/NpiProviderCountOutputTypeCountPaymentsArgs.schema.ts",
"schemas/objects/NpiProviderCountOutputTypeCountCommissionBatchesArgs.schema.ts", "schemas/objects/NpiProviderCountOutputTypeCountCommissionBatchesArgs.schema.ts",
"schemas/objects/NpiProviderCountOutputTypeCountAppointmentProceduresArgs.schema.ts", "schemas/objects/NpiProviderCountOutputTypeCountAppointmentProceduresArgs.schema.ts",
"schemas/objects/NpiProviderCountOutputTypeCountAppointmentsArgs.schema.ts",
"schemas/objects/ClaimCountOutputTypeArgs.schema.ts", "schemas/objects/ClaimCountOutputTypeArgs.schema.ts",
"schemas/objects/ClaimCountOutputTypeCountServiceLinesArgs.schema.ts", "schemas/objects/ClaimCountOutputTypeCountServiceLinesArgs.schema.ts",
"schemas/objects/ClaimCountOutputTypeCountClaimFilesArgs.schema.ts", "schemas/objects/ClaimCountOutputTypeCountClaimFilesArgs.schema.ts",
@@ -1 +1 @@
{"version":3,"file":"decimal-helpers.d.ts","sourceRoot":"","sources":["decimal-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAMhD,eAAO,MAAM,mBAAmB,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAM9D,CAAC;AAGH,eAAO,MAAM,oBAAoB,QAAuE,CAAC;AAEzG,eAAO,MAAM,mBAAmB,GAC9B,IAAI,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,aAAa,KAChD,CAAC,IAAI,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,aAiBhC,CAAC"} {"version":3,"file":"decimal-helpers.d.ts","sourceRoot":"","sources":["decimal-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAOrD,eAAO,MAAM,mBAAmB,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,CAM9D,CAAC;AAGH,eAAO,MAAM,oBAAoB,QAAuE,CAAC;AAEzG,eAAO,MAAM,mBAAmB,GAC9B,IAAI,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,aAAa,KAChD,CAAC,IAAI,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC,aAiBhC,CAAC"}
@@ -38,7 +38,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.isValidDecimalInput = exports.DECIMAL_STRING_REGEX = exports.DecimalJSLikeSchema = void 0; exports.isValidDecimalInput = exports.DECIMAL_STRING_REGEX = exports.DecimalJSLikeSchema = void 0;
const z = __importStar(require("zod")); const z = __importStar(require("zod"));
const prisma_1 = require("../../generated/prisma");
const decimal_js_1 = __importDefault(require("decimal.js")); const decimal_js_1 = __importDefault(require("decimal.js"));
// DECIMAL HELPERS // DECIMAL HELPERS
//------------------------------------------------------ //------------------------------------------------------
@@ -56,7 +55,7 @@ const isValidDecimalInput = (v) => {
return false; return false;
return ( return (
// Explicit instance checks first // Explicit instance checks first
v instanceof prisma_1.Prisma.Decimal || v instanceof decimal_js_1.default ||
// If Decimal.js is present and imported by the generator, this symbol exists at runtime // If Decimal.js is present and imported by the generator, this symbol exists at runtime
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Decimal may be undefined when not installed; codegen controls the import // @ts-ignore - Decimal may be undefined when not installed; codegen controls the import
@@ -1,7 +1,8 @@
import * as z from 'zod'; import * as z from 'zod';
import { Prisma } from '../../generated/prisma'; import type { Prisma } from '../../generated/prisma';
import Decimal from 'decimal.js';
import Decimal from "decimal.js";
// DECIMAL HELPERS // DECIMAL HELPERS
//------------------------------------------------------ //------------------------------------------------------
@@ -23,7 +24,7 @@ export const isValidDecimalInput = (
if (v === undefined || v === null) return false; if (v === undefined || v === null) return false;
return ( return (
// Explicit instance checks first // Explicit instance checks first
v instanceof Prisma.Decimal || v instanceof Decimal ||
// If Decimal.js is present and imported by the generator, this symbol exists at runtime // If Decimal.js is present and imported by the generator, this symbol exists at runtime
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Decimal may be undefined when not installed; codegen controls the import // @ts-ignore - Decimal may be undefined when not installed; codegen controls the import
@@ -0,0 +1,47 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LabRxTemplateAggregateZodSchema = exports.LabRxTemplateAggregateSchema = void 0;
const z = __importStar(require("zod"));
const LabRxTemplateOrderByWithRelationInput_schema_1 = require("./objects/LabRxTemplateOrderByWithRelationInput.schema");
const LabRxTemplateWhereInput_schema_1 = require("./objects/LabRxTemplateWhereInput.schema");
const LabRxTemplateWhereUniqueInput_schema_1 = require("./objects/LabRxTemplateWhereUniqueInput.schema");
const LabRxTemplateCountAggregateInput_schema_1 = require("./objects/LabRxTemplateCountAggregateInput.schema");
const LabRxTemplateMinAggregateInput_schema_1 = require("./objects/LabRxTemplateMinAggregateInput.schema");
const LabRxTemplateMaxAggregateInput_schema_1 = require("./objects/LabRxTemplateMaxAggregateInput.schema");
const LabRxTemplateAvgAggregateInput_schema_1 = require("./objects/LabRxTemplateAvgAggregateInput.schema");
const LabRxTemplateSumAggregateInput_schema_1 = require("./objects/LabRxTemplateSumAggregateInput.schema");
exports.LabRxTemplateAggregateSchema = z.object({ orderBy: z.union([LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema, LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema.array()]).optional(), where: LabRxTemplateWhereInput_schema_1.LabRxTemplateWhereInputObjectSchema.optional(), cursor: LabRxTemplateWhereUniqueInput_schema_1.LabRxTemplateWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), _count: z.union([z.literal(true), LabRxTemplateCountAggregateInput_schema_1.LabRxTemplateCountAggregateInputObjectSchema]).optional(), _min: LabRxTemplateMinAggregateInput_schema_1.LabRxTemplateMinAggregateInputObjectSchema.optional(), _max: LabRxTemplateMaxAggregateInput_schema_1.LabRxTemplateMaxAggregateInputObjectSchema.optional(), _avg: LabRxTemplateAvgAggregateInput_schema_1.LabRxTemplateAvgAggregateInputObjectSchema.optional(), _sum: LabRxTemplateSumAggregateInput_schema_1.LabRxTemplateSumAggregateInputObjectSchema.optional() }).strict();
exports.LabRxTemplateAggregateZodSchema = z.object({ orderBy: z.union([LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema, LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema.array()]).optional(), where: LabRxTemplateWhereInput_schema_1.LabRxTemplateWhereInputObjectSchema.optional(), cursor: LabRxTemplateWhereUniqueInput_schema_1.LabRxTemplateWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), _count: z.union([z.literal(true), LabRxTemplateCountAggregateInput_schema_1.LabRxTemplateCountAggregateInputObjectSchema]).optional(), _min: LabRxTemplateMinAggregateInput_schema_1.LabRxTemplateMinAggregateInputObjectSchema.optional(), _max: LabRxTemplateMaxAggregateInput_schema_1.LabRxTemplateMaxAggregateInputObjectSchema.optional(), _avg: LabRxTemplateAvgAggregateInput_schema_1.LabRxTemplateAvgAggregateInputObjectSchema.optional(), _sum: LabRxTemplateSumAggregateInput_schema_1.LabRxTemplateSumAggregateInputObjectSchema.optional() }).strict();
@@ -0,0 +1,47 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SeleniumSettingsAggregateZodSchema = exports.SeleniumSettingsAggregateSchema = void 0;
const z = __importStar(require("zod"));
const SeleniumSettingsOrderByWithRelationInput_schema_1 = require("./objects/SeleniumSettingsOrderByWithRelationInput.schema");
const SeleniumSettingsWhereInput_schema_1 = require("./objects/SeleniumSettingsWhereInput.schema");
const SeleniumSettingsWhereUniqueInput_schema_1 = require("./objects/SeleniumSettingsWhereUniqueInput.schema");
const SeleniumSettingsCountAggregateInput_schema_1 = require("./objects/SeleniumSettingsCountAggregateInput.schema");
const SeleniumSettingsMinAggregateInput_schema_1 = require("./objects/SeleniumSettingsMinAggregateInput.schema");
const SeleniumSettingsMaxAggregateInput_schema_1 = require("./objects/SeleniumSettingsMaxAggregateInput.schema");
const SeleniumSettingsAvgAggregateInput_schema_1 = require("./objects/SeleniumSettingsAvgAggregateInput.schema");
const SeleniumSettingsSumAggregateInput_schema_1 = require("./objects/SeleniumSettingsSumAggregateInput.schema");
exports.SeleniumSettingsAggregateSchema = z.object({ orderBy: z.union([SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema, SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema.array()]).optional(), where: SeleniumSettingsWhereInput_schema_1.SeleniumSettingsWhereInputObjectSchema.optional(), cursor: SeleniumSettingsWhereUniqueInput_schema_1.SeleniumSettingsWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), _count: z.union([z.literal(true), SeleniumSettingsCountAggregateInput_schema_1.SeleniumSettingsCountAggregateInputObjectSchema]).optional(), _min: SeleniumSettingsMinAggregateInput_schema_1.SeleniumSettingsMinAggregateInputObjectSchema.optional(), _max: SeleniumSettingsMaxAggregateInput_schema_1.SeleniumSettingsMaxAggregateInputObjectSchema.optional(), _avg: SeleniumSettingsAvgAggregateInput_schema_1.SeleniumSettingsAvgAggregateInputObjectSchema.optional(), _sum: SeleniumSettingsSumAggregateInput_schema_1.SeleniumSettingsSumAggregateInputObjectSchema.optional() }).strict();
exports.SeleniumSettingsAggregateZodSchema = z.object({ orderBy: z.union([SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema, SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema.array()]).optional(), where: SeleniumSettingsWhereInput_schema_1.SeleniumSettingsWhereInputObjectSchema.optional(), cursor: SeleniumSettingsWhereUniqueInput_schema_1.SeleniumSettingsWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), _count: z.union([z.literal(true), SeleniumSettingsCountAggregateInput_schema_1.SeleniumSettingsCountAggregateInputObjectSchema]).optional(), _min: SeleniumSettingsMinAggregateInput_schema_1.SeleniumSettingsMinAggregateInputObjectSchema.optional(), _max: SeleniumSettingsMaxAggregateInput_schema_1.SeleniumSettingsMaxAggregateInputObjectSchema.optional(), _avg: SeleniumSettingsAvgAggregateInput_schema_1.SeleniumSettingsAvgAggregateInputObjectSchema.optional(), _sum: SeleniumSettingsSumAggregateInput_schema_1.SeleniumSettingsSumAggregateInputObjectSchema.optional() }).strict();
@@ -0,0 +1,43 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LabRxTemplateCountZodSchema = exports.LabRxTemplateCountSchema = void 0;
const z = __importStar(require("zod"));
const LabRxTemplateOrderByWithRelationInput_schema_1 = require("./objects/LabRxTemplateOrderByWithRelationInput.schema");
const LabRxTemplateWhereInput_schema_1 = require("./objects/LabRxTemplateWhereInput.schema");
const LabRxTemplateWhereUniqueInput_schema_1 = require("./objects/LabRxTemplateWhereUniqueInput.schema");
const LabRxTemplateCountAggregateInput_schema_1 = require("./objects/LabRxTemplateCountAggregateInput.schema");
exports.LabRxTemplateCountSchema = z.object({ orderBy: z.union([LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema, LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema.array()]).optional(), where: LabRxTemplateWhereInput_schema_1.LabRxTemplateWhereInputObjectSchema.optional(), cursor: LabRxTemplateWhereUniqueInput_schema_1.LabRxTemplateWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), select: z.union([z.literal(true), LabRxTemplateCountAggregateInput_schema_1.LabRxTemplateCountAggregateInputObjectSchema]).optional() }).strict();
exports.LabRxTemplateCountZodSchema = z.object({ orderBy: z.union([LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema, LabRxTemplateOrderByWithRelationInput_schema_1.LabRxTemplateOrderByWithRelationInputObjectSchema.array()]).optional(), where: LabRxTemplateWhereInput_schema_1.LabRxTemplateWhereInputObjectSchema.optional(), cursor: LabRxTemplateWhereUniqueInput_schema_1.LabRxTemplateWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), select: z.union([z.literal(true), LabRxTemplateCountAggregateInput_schema_1.LabRxTemplateCountAggregateInputObjectSchema]).optional() }).strict();
@@ -0,0 +1,43 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SeleniumSettingsCountZodSchema = exports.SeleniumSettingsCountSchema = void 0;
const z = __importStar(require("zod"));
const SeleniumSettingsOrderByWithRelationInput_schema_1 = require("./objects/SeleniumSettingsOrderByWithRelationInput.schema");
const SeleniumSettingsWhereInput_schema_1 = require("./objects/SeleniumSettingsWhereInput.schema");
const SeleniumSettingsWhereUniqueInput_schema_1 = require("./objects/SeleniumSettingsWhereUniqueInput.schema");
const SeleniumSettingsCountAggregateInput_schema_1 = require("./objects/SeleniumSettingsCountAggregateInput.schema");
exports.SeleniumSettingsCountSchema = z.object({ orderBy: z.union([SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema, SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema.array()]).optional(), where: SeleniumSettingsWhereInput_schema_1.SeleniumSettingsWhereInputObjectSchema.optional(), cursor: SeleniumSettingsWhereUniqueInput_schema_1.SeleniumSettingsWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), select: z.union([z.literal(true), SeleniumSettingsCountAggregateInput_schema_1.SeleniumSettingsCountAggregateInputObjectSchema]).optional() }).strict();
exports.SeleniumSettingsCountZodSchema = z.object({ orderBy: z.union([SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema, SeleniumSettingsOrderByWithRelationInput_schema_1.SeleniumSettingsOrderByWithRelationInputObjectSchema.array()]).optional(), where: SeleniumSettingsWhereInput_schema_1.SeleniumSettingsWhereInputObjectSchema.optional(), cursor: SeleniumSettingsWhereUniqueInput_schema_1.SeleniumSettingsWhereUniqueInputObjectSchema.optional(), take: z.number().optional(), skip: z.number().optional(), select: z.union([z.literal(true), SeleniumSettingsCountAggregateInput_schema_1.SeleniumSettingsCountAggregateInputObjectSchema]).optional() }).strict();
@@ -0,0 +1,41 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LabRxTemplateCreateManyAndReturnZodSchema = exports.LabRxTemplateCreateManyAndReturnSchema = void 0;
const z = __importStar(require("zod"));
const LabRxTemplateSelect_schema_1 = require("./objects/LabRxTemplateSelect.schema");
const LabRxTemplateCreateManyInput_schema_1 = require("./objects/LabRxTemplateCreateManyInput.schema");
exports.LabRxTemplateCreateManyAndReturnSchema = z.object({ select: LabRxTemplateSelect_schema_1.LabRxTemplateSelectObjectSchema.optional(), data: z.union([LabRxTemplateCreateManyInput_schema_1.LabRxTemplateCreateManyInputObjectSchema, z.array(LabRxTemplateCreateManyInput_schema_1.LabRxTemplateCreateManyInputObjectSchema)]), skipDuplicates: z.boolean().optional() }).strict();
exports.LabRxTemplateCreateManyAndReturnZodSchema = z.object({ select: LabRxTemplateSelect_schema_1.LabRxTemplateSelectObjectSchema.optional(), data: z.union([LabRxTemplateCreateManyInput_schema_1.LabRxTemplateCreateManyInputObjectSchema, z.array(LabRxTemplateCreateManyInput_schema_1.LabRxTemplateCreateManyInputObjectSchema)]), skipDuplicates: z.boolean().optional() }).strict();

Some files were not shown because too many files have changed in this diff Show More