Compare commits
58
Commits
f712479882
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b899659e13 | ||
|
|
52ddfd9f57 | ||
|
|
f8235787db | ||
|
|
b860fd213f | ||
|
|
116889dd4e | ||
|
|
ad9d311a99 | ||
|
|
374f96399e | ||
|
|
e934d6bce5 | ||
|
|
304441d95a | ||
|
|
5044fecb07 | ||
|
|
efb85d0b92 | ||
|
|
41bed21302 | ||
|
|
67e71a02e4 | ||
|
|
ff79cd1643 | ||
|
|
0cd095330a | ||
|
|
55ce52f276 | ||
|
|
cef928be97 | ||
|
|
06dc63c53e | ||
|
|
089e94ba88 | ||
|
|
ed5a8af835 | ||
|
|
9885a3d35b | ||
|
|
75538126f2 | ||
|
|
55d798b45f | ||
|
|
9efa8f6e0a | ||
|
|
5cdd3452f2 | ||
|
|
3f83794ac5 | ||
|
|
76bd99f7ff | ||
|
|
44c6d2f833 | ||
|
|
6d0838446b | ||
|
|
65dc795eb2 | ||
|
|
ad2e15ec62 | ||
|
|
59f064583a | ||
|
|
ae961781ac | ||
|
|
f457d6282a | ||
|
|
d5cecb0b54 | ||
|
|
870bda5950 | ||
|
|
6b9b3c41f2 | ||
|
|
a42ef5f570 | ||
|
|
b1932fa3d6 | ||
|
|
ca568de817 | ||
|
|
b30bed8903 | ||
|
|
8c65c3d625 | ||
|
|
27e5b73138 | ||
|
|
78ee0b92fe | ||
|
|
6062b646ce | ||
|
|
158634f8de | ||
|
|
75781b92a6 | ||
|
|
e49dfdbee9 | ||
|
|
f29064883c | ||
|
|
5979d27c57 | ||
|
|
95abbabb36 | ||
|
|
6004bcb73d | ||
|
|
c95d08c951 | ||
|
|
34312b62bc | ||
|
|
8b245d00c6 | ||
|
|
c4c69bfd62 | ||
|
|
0d2fe63b9b | ||
|
|
44dea49fd1 |
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"is_task_list_visible": true,
|
"is_task_list_visible": true,
|
||||||
"active_task": "backend#dev"
|
"active_task": "frontend#dev"
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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+/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>) || {};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
(uploadedFiles || []).map((file) => ({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type,
|
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,6 +445,7 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
Or click to browse files
|
Or click to browse files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -447,11 +454,33 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
handleBrowseClick();
|
handleBrowseClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Browse files
|
Choose from This Computer
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// page's tryAppointmentFromChatbot picks this up once the check running below completes.
|
||||||
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
|
sessionStorage.setItem("chatbot_appt_after_eligibility", JSON.stringify({ memberId: eligibilityIdData.memberId, date: targetDate ?? null }));
|
||||||
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
prefillAndNavigate(eligibilityIdData.memberId, eligibilityIdData.dob, eligibilityIdData.autoCheck);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
|
(data) => {
|
||||||
handleSubmit2(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 & 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 & time?</text>
|
<text x={lcx} y={yes1y+31} textAnchor="middle" fontSize={10} fontWeight="600" fill="#C2410C">Reschedule: What day & time?</text>
|
||||||
<text x={lcx} y={yes1y+52} textAnchor="middle" fontSize={9} fill="#6B7280" fontStyle="italic">"See you on [date & time]"</text>
|
<text x={lcx} y={yes1y+46} textAnchor="middle" fontSize={10} fontWeight="600" fill="#5B21B6">Recall: What day & 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 & 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 };
|
||||||
|
}
|
||||||
@@ -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 handleSave = async () => {
|
const clearActive = () => {
|
||||||
if (!screenshot || !selectedPatient) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const croppedImage =
|
|
||||||
completedCrop && imgRef.current
|
|
||||||
? getCroppedDataUrl(imgRef.current, completedCrop)
|
|
||||||
: 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({
|
|
||||||
title: "Saved",
|
|
||||||
description: `Screenshot saved to ${patientName}'s attachments.`,
|
|
||||||
});
|
|
||||||
setScreenshot(null);
|
setScreenshot(null);
|
||||||
setFileName("");
|
setFileName("");
|
||||||
setCrop(undefined);
|
setCrop(undefined);
|
||||||
setCompletedCrop(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 () => {
|
||||||
|
if (!selectedPatient) return;
|
||||||
|
const items = getPendingItems();
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const patientName = getPatientName(selectedPatient);
|
||||||
|
const files = await itemsToFiles(items);
|
||||||
|
await uploadToPatient(selectedPatient.id, patientName, files, false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Saved",
|
||||||
|
description: `${items.length} screenshot${items.length > 1 ? "s" : ""} saved to ${patientName}'s attachments.`,
|
||||||
|
});
|
||||||
|
setBatch([]);
|
||||||
|
clearActive();
|
||||||
} 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 & Check
|
||||||
|
Eligibility" without selecting one to create a patient automatically.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button onClick={handleCapture} disabled={isCapturing}>
|
<Button onClick={handleCapture} disabled={isCapturing}>
|
||||||
<Camera className="h-4 w-4 mr-2" />
|
<Camera className="h-4 w-4 mr-2" />
|
||||||
{countdown !== null
|
{countdown !== null
|
||||||
? `Capturing in ${countdown}...`
|
? `Capturing in ${countdown}...`
|
||||||
: isCapturing
|
: isCapturing
|
||||||
? "Waiting for screen share..."
|
? "Waiting for screen share..."
|
||||||
|
: batch.length > 0
|
||||||
|
? "Take Another Screenshot"
|
||||||
: "Take Screenshot"}
|
: "Take Screenshot"}
|
||||||
</Button>
|
</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 & Check Eligibility" to create one automatically.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -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,90 +225,195 @@ 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
|
||||||
</div>
|
of the actions.
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm">
|
</CardDescription>
|
||||||
{[
|
</CardHeader>
|
||||||
"Retrieve data in this app\n(eligibility, claim, payment)",
|
<CardContent className="space-y-4">
|
||||||
"Agent identifies the active\nfield in your dental software",
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
"Types the value directly\nvia keyboard automation",
|
<div className="w-56">
|
||||||
].map((step, i) => (
|
<Select value={software} onValueChange={(v) => setSoftware(v as SoftwareId)}>
|
||||||
<div key={i} className="flex items-center gap-3 flex-1">
|
<SelectTrigger>
|
||||||
<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">
|
<SelectValue placeholder="Choose software..." />
|
||||||
{i + 1}
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<p className="whitespace-pre-line text-muted-foreground leading-snug">{step}</p>
|
{SOFTWARE_OPTIONS.map((s) => (
|
||||||
{i < 2 && <ArrowRight className="h-4 w-4 text-muted-foreground/40 hidden sm:block flex-shrink-0" />}
|
<SelectItem key={s.id} value={s.id}>
|
||||||
</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>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* What it will type */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-base font-semibold">What it will type</h2>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
{CAPABILITIES.map((cap) => (
|
|
||||||
<Card key={cap.title}>
|
|
||||||
<CardContent className="py-5 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{cap.icon}
|
|
||||||
<span className="text-sm font-medium">{cap.title}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
{cap.description}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Supported software */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-base font-semibold">Planned software support</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{SOFTWARE_TARGETS.map((s) => (
|
|
||||||
<span
|
|
||||||
key={s.name}
|
|
||||||
className={`text-sm font-medium px-3 py-1.5 rounded-lg border ${s.color}`}
|
|
||||||
>
|
|
||||||
{s.name}
|
{s.name}
|
||||||
</span>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
<p className="text-xs text-muted-foreground">
|
</Select>
|
||||||
Each software has its own field layout. The agent will include pre-built templates
|
|
||||||
for common workflows in each platform.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Why */}
|
{agentConnected ? (
|
||||||
<Card className="border-violet-200 bg-violet-50/50">
|
<Badge variant="outline" className="gap-1.5 text-muted-foreground border-emerald-200 bg-emerald-50">
|
||||||
<CardContent className="py-5 flex items-start gap-3">
|
<Circle className="h-2 w-2 fill-emerald-500 text-emerald-500" />
|
||||||
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" />
|
{agents.length} PC{agents.length > 1 ? "s" : ""} Connected
|
||||||
<div className="space-y-1">
|
</Badge>
|
||||||
<p className="text-sm font-medium text-violet-900">Why this matters</p>
|
) : (
|
||||||
<p className="text-sm text-violet-700 leading-relaxed">
|
<Badge variant="outline" className="gap-1.5 text-muted-foreground border-red-200 bg-red-50">
|
||||||
Staff currently check eligibility or look up a claim here, then manually retype
|
<Circle className="h-2 w-2 fill-red-500 text-red-500" />
|
||||||
every value into Open Dental or Eaglesoft. This agent eliminates that step entirely —
|
No PC Connected
|
||||||
one click sends the data straight into the right field.
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedPatient ? (
|
||||||
|
<Badge variant="secondary">Patient: {getPatientName(selectedPatient)}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Select a patient below</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temporary debug toggle — remove once the vision-based patient-row click is
|
||||||
|
confirmed reliable */}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Checkbox checked={ratioTest} onCheckedChange={(v) => setRatioTest(v === true)} />
|
||||||
|
Ratio test: skip AI lookup for the patient row, move to fixed ratio (0.31, 0.21) instead
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Connected PCs */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Windows PC to control</p>
|
||||||
|
{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>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Patient search */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Patient Records</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select the patient to use for the action above.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PatientTable allowCheckbox={true} onSelectPatient={setSelectedPatient} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
{!runStartError && startCountdown === null && liveSteps.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Starting run...</p>
|
||||||
|
)}
|
||||||
|
{liveSteps.map((step, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 text-sm">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{step.status === "running" && <Loader2 className="h-4 w-4 animate-spin text-violet-500" />}
|
||||||
|
{step.status === "done" && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
|
||||||
|
{step.status === "error" && <XCircle className="h-4 w-4 text-destructive" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="leading-snug pt-0.5">{step.label}</p>
|
||||||
|
{step.error && <p className="text-xs text-destructive mt-0.5">{step.error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 text-sm">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground leading-snug pt-0.5">{step}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
// A malformed date (e.g. hand-edited in the chatbot) must not abort the rest of
|
||||||
|
// this function — otherwise pendingAutoCheck/prefillTick below never get set and
|
||||||
|
// the auto-trigger effect silently never fires (no error, Selenium never launches).
|
||||||
|
try {
|
||||||
const normalized = /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(dob)
|
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")}`; })()
|
? (() => { const [m, d, y] = dob.split("/"); return `${y}-${m!.padStart(2,"0")}-${d!.padStart(2,"0")}`; })()
|
||||||
: dob;
|
: dob;
|
||||||
setDateOfBirth(parseLocalDate(normalized));
|
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,12 +260,16 @@ export default function InsuranceStatusPage() {
|
|||||||
if (patient) {
|
if (patient) {
|
||||||
setFirstName(patient.firstName ?? "");
|
setFirstName(patient.firstName ?? "");
|
||||||
setLastName(patient.lastName ?? "");
|
setLastName(patient.lastName ?? "");
|
||||||
|
if (suppressAutoLookupDobRef.current) {
|
||||||
|
suppressAutoLookupDobRef.current = false;
|
||||||
|
} else {
|
||||||
const dob =
|
const dob =
|
||||||
typeof patient.dateOfBirth === "string"
|
typeof patient.dateOfBirth === "string"
|
||||||
? parseLocalDate(patient.dateOfBirth)
|
? parseLocalDate(patient.dateOfBirth)
|
||||||
: patient.dateOfBirth ?? null;
|
: patient.dateOfBirth ?? null;
|
||||||
setDateOfBirth(dob);
|
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
|
||||||
|
// longer used — all MH eligibility checks route through the CMSP/MH worker.
|
||||||
handleCMSPButton();
|
handleCMSPButton();
|
||||||
} else {
|
|
||||||
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 & 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 & History & Remaining
|
MH Eligibility & Remaining & 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 & Remaining"
|
title="MH Eligibility, Remaining & 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,3 +1,4 @@
|
|||||||
google_credentials.json
|
google_credentials.json
|
||||||
|
astute-office-469222-k3-ba714473050d.json
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +744,26 @@ 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}]",
|
||||||
]:
|
]
|
||||||
|
|
||||||
|
deadline = time.monotonic() + row_timeout
|
||||||
|
clicked = False
|
||||||
|
while time.monotonic() < deadline and not clicked:
|
||||||
|
cell = None
|
||||||
|
for xpath in xpaths:
|
||||||
try:
|
try:
|
||||||
cell = self.driver.find_element(By.XPATH, xpath)
|
cell = self.driver.find_element(By.XPATH, xpath)
|
||||||
break
|
break
|
||||||
@@ -759,12 +776,19 @@ class AutomationCCAClaimSubmit:
|
|||||||
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});",
|
"arguments[0].scrollIntoView({block:'nearest',inline:'nearest'});",
|
||||||
cell)
|
cell)
|
||||||
cell.click()
|
cell.click()
|
||||||
|
clicked = True
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
print(f"[CCA Claim grid] Clicked row={row_num} col={col_idx}")
|
print(f"[CCA Claim grid] Clicked row={row_num} col={col_expr}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[CCA Claim grid] Cell click failed: {e}")
|
print(f"[CCA Claim grid] Cell click failed, retrying: {e}")
|
||||||
|
time.sleep(0.3)
|
||||||
else:
|
else:
|
||||||
print(f"[CCA Claim grid] Cell not found row={row_num} col={col_idx}")
|
print(f"[CCA Claim grid] Cell not found yet row={row_num} col={col_expr}, retrying")
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
if not clicked:
|
||||||
|
print(f"[CCA Claim grid] Gave up clicking row={row_num} col={col_expr} after {row_timeout}s")
|
||||||
|
return False
|
||||||
|
|
||||||
# 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:
|
||||||
for si, char in enumerate(surface_chars[:5]):
|
|
||||||
surf_ids = ["ServicesSURF1","ServicesSURF2","ServicesSURF3",
|
surf_ids = ["ServicesSURF1","ServicesSURF2","ServicesSURF3",
|
||||||
"ServicesSURF4","ServicesSURF5"]
|
"ServicesSURF4","ServicesSURF5"]
|
||||||
click_cell_and_fill(5 + si, surf_ids[si], char)
|
for si, char in enumerate(surface_chars[:5]):
|
||||||
|
click_cell_and_fill(col_map[f"SURF{si+1}"], 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,6 +400,24 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
try:
|
try:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
eligibilityText = "unknown"
|
||||||
|
foundMemberId = self.memberId or ""
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# DDMA now sometimes skips the family-members results table entirely and
|
||||||
|
# navigates straight to the single-patient detail page (when Subscriber ID +
|
||||||
|
# DOB match exactly one person). In that case there's no members table to
|
||||||
|
# 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
|
||||||
|
|
||||||
|
if not already_on_detail_page:
|
||||||
# Wait for results table, then pause for full render
|
# Wait for results table, then pause for full render
|
||||||
try:
|
try:
|
||||||
WebDriverWait(self.driver, 10).until(
|
WebDriverWait(self.driver, 10).until(
|
||||||
@@ -399,16 +427,12 @@ class AutomationDeltaDentalMAEligibilityCheck:
|
|||||||
except TimeoutException:
|
except TimeoutException:
|
||||||
print("[DDMA step2] Warning: Results table not found within timeout")
|
print("[DDMA step2] Warning: Results table not found within timeout")
|
||||||
|
|
||||||
eligibilityText = "unknown"
|
|
||||||
foundMemberId = self.memberId or ""
|
|
||||||
patientName = ""
|
|
||||||
|
|
||||||
# The Members table lists the whole family sharing this subscriber ID —
|
# 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).
|
# 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")
|
rows = self.driver.find_elements(By.XPATH, "//table//tbody//tr")
|
||||||
print(f"[DDMA step2] Found {len(rows)} family member row(s)")
|
print(f"[DDMA step2] Found {len(rows)} family member row(s)")
|
||||||
|
else:
|
||||||
target_row = None
|
print(f"[DDMA step2] Already on detail page: {self.driver.current_url}")
|
||||||
|
|
||||||
if self.dateOfBirth and rows:
|
if self.dateOfBirth and rows:
|
||||||
try:
|
try:
|
||||||
@@ -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:
|
try:
|
||||||
if listbox_id:
|
|
||||||
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,15 +816,29 @@ 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}")
|
||||||
|
|
||||||
|
advanced = False
|
||||||
|
for attempt in range(3):
|
||||||
btn = WebDriverWait(self.driver, 15).until(
|
btn = WebDriverWait(self.driver, 15).until(
|
||||||
EC.element_to_be_clickable((By.XPATH,
|
EC.element_to_be_clickable((By.XPATH,
|
||||||
"//button[@data-testid='next-step-btn'] | "
|
"//button[@data-testid='next-step-btn'] | "
|
||||||
"//button[@aria-label='Next step']"
|
"//button[@aria-label='Next step']"
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
disabled = (btn.get_attribute("disabled") is not None) or \
|
||||||
|
((btn.get_attribute("aria-disabled") or "").lower() == "true")
|
||||||
|
if disabled:
|
||||||
|
print(f"[TuftsSCO PreAuth step6] 'Next step' is disabled (attempt {attempt + 1}) — "
|
||||||
|
f"a form field may still be invalid/empty; waiting before retry")
|
||||||
|
time.sleep(1.5)
|
||||||
|
continue
|
||||||
|
|
||||||
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn)
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", btn)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self.driver.execute_script("""
|
self.driver.execute_script("""
|
||||||
@@ -795,9 +848,24 @@ class AutomationTuftsSCOPreAuth:
|
|||||||
el.dispatchEvent(new PointerEvent('pointerup', {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}));
|
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
|
||||||
""", btn)
|
""", btn)
|
||||||
print("[TuftsSCO PreAuth step6] Clicked 'Next step'")
|
print(f"[TuftsSCO PreAuth step6] Clicked 'Next step' (attempt {attempt + 1})")
|
||||||
time.sleep(2)
|
|
||||||
|
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,19 +873,48 @@ 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']",
|
||||||
|
"//*[contains(translate(@aria-label, 'ACKNOWLEDGE', 'acknowledge'),'acknowledge')]",
|
||||||
|
"//input[@type='checkbox']",
|
||||||
|
"//*[@role='checkbox']",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
checkbox = WebDriverWait(self.driver, 5).until(
|
||||||
|
EC.presence_of_element_located((By.XPATH, xpath))
|
||||||
)
|
)
|
||||||
|
print(f"[TuftsSCO PreAuth step7] Found acknowledgement checkbox via: {xpath}")
|
||||||
|
break
|
||||||
|
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)
|
self.driver.execute_script("arguments[0].scrollIntoView({block:'center'});", checkbox)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
self.driver.execute_script("""
|
self.driver.execute_script("""
|
||||||
@@ -827,8 +924,14 @@ class AutomationTuftsSCOPreAuth:
|
|||||||
el.dispatchEvent(new PointerEvent('pointerup', {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}));
|
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, composed:true}));
|
||||||
""", checkbox)
|
""", checkbox)
|
||||||
print("[TuftsSCO PreAuth step7] Checked acknowledgement checkbox")
|
|
||||||
time.sleep(0.5)
|
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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "windowsagent",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Generated
+582
-6
@@ -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
@@ -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',
|
||||||
|
|||||||
+637
-60
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -120,6 +123,7 @@ model Appointment {
|
|||||||
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])
|
||||||
|
npiProvider NpiProvider? @relation(fields: [npiProviderId], references: [id])
|
||||||
procedures AppointmentProcedure[]
|
procedures AppointmentProcedure[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
files AppointmentFile[]
|
files AppointmentFile[]
|
||||||
@@ -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])
|
||||||
|
|||||||
+5
@@ -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";
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "remainingBenefit" TEXT;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "thirdPartyInsurance" TEXT;
|
||||||
@@ -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"
|
||||||
@@ -120,6 +123,7 @@ model Appointment {
|
|||||||
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])
|
||||||
|
npiProvider NpiProvider? @relation(fields: [npiProviderId], references: [id])
|
||||||
procedures AppointmentProcedure[]
|
procedures AppointmentProcedure[]
|
||||||
claims Claim[]
|
claims Claim[]
|
||||||
files AppointmentFile[]
|
files AppointmentFile[]
|
||||||
@@ -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
Reference in New Issue
Block a user