1240 lines
47 KiB
Markdown
1240 lines
47 KiB
Markdown
# Dental Manager - Starter
|
||
|
||
A monorepo setup to manage both Backend and Frontend of the Dental Manager application.
|
||
|
||
## 🔒 Security Setup (First Thing on a New PC)
|
||
|
||
### Step 1 — Change the user password
|
||
|
||
```bash
|
||
passwd
|
||
```
|
||
|
||
It will ask for the current password, then the new password twice.
|
||
|
||
### Step 2 — Set the root password
|
||
|
||
```bash
|
||
sudo passwd root
|
||
```
|
||
|
||
Enter a strong password. This prevents anyone from getting root access with a blank password.
|
||
|
||
### Step 3 — Check that SSH is not running
|
||
|
||
```bash
|
||
systemctl status sshd
|
||
```
|
||
|
||
If it shows "inactive (dead)" or "not found", you're safe — no one can remotely access this PC via SSH.
|
||
|
||
If it shows "active (running)", disable it:
|
||
|
||
```bash
|
||
sudo systemctl stop sshd && sudo systemctl disable sshd
|
||
```
|
||
|
||
---
|
||
|
||
## ⚡ Quick Install (Fresh Machine)
|
||
|
||
Do these three things in order. The script in step C runs Steps 2–14 and 16 of the Setup Guide below for you.
|
||
|
||
### A — Install VS Code (manually)
|
||
|
||
1. In a browser, go to https://code.visualstudio.com/download and download the **.deb** (Debian/Ubuntu) file
|
||
2. Install it from a terminal:
|
||
|
||
```sh
|
||
sudo apt install -y ~/Downloads/code_*.deb
|
||
|
||
# Verify
|
||
code --version
|
||
```
|
||
|
||
### B — Add the Claude Code extension in VS Code
|
||
|
||
1. Open VS Code
|
||
2. Open the Extensions panel (`Ctrl+Shift+X`)
|
||
3. Search for **Claude Code** (published by Anthropic) and click **Install**
|
||
4. Click the Claude icon in the sidebar and sign in
|
||
|
||
### C — Run the install script
|
||
|
||
Clone the repository to `~/Desktop/DentalManagement09` if it isn't there yet (needs git: `sudo apt install -y git`), then run:
|
||
|
||
```sh
|
||
cd ~/Desktop/DentalManagement09
|
||
bash install-all.sh
|
||
```
|
||
|
||
Enter your password when it asks. The script installs Node.js, Python, Chrome, PostgreSQL, Redis, rclone and nginx; installs the Node and Python dependencies; creates any missing `.env` files (existing ones are kept); sets up the database; and creates the desktop shortcuts. It is safe to re-run.
|
||
|
||
### D — Let git remember your Gitea login (one time)
|
||
|
||
So `git push` doesn't ask for the password every time:
|
||
|
||
```sh
|
||
git config --global credential.helper store
|
||
cd ~/Desktop/DentalManagement09
|
||
git push origin main
|
||
```
|
||
|
||
Enter the Gitea username and password once when asked — git saves them and won't prompt again.
|
||
|
||
> Prefer a Gitea **access token** over your real password: log into https://gitea.mydentalofficemanagement.com → profile picture → **Settings** → **Applications** → **Generate New Token** (give it **Read and Write** access to **repository**), and paste the token when git asks for the password. A token can be revoked in Gitea at any time without changing your password.
|
||
|
||
> `store` saves the login in plain text in `~/.git-credentials`. The keyring-based helper isn't an option here because Step 1a removes gnome-keyring.
|
||
|
||
Still done by hand afterwards: the Security Setup above, Step 1 (auto-login / keyring), Step 12a (Cloudflare subdomain), the LAN HTTPS certificate (the script only enables the nginx site once that certificate exists), and the optional Steps 17–18.
|
||
|
||
---
|
||
|
||
## 🖥️ Setup Guide (Fresh Machine)
|
||
|
||
Follow these steps in order after cloning the repository. (Steps 2–14 and 16 are automated by `install-all.sh` — see Quick Install above.)
|
||
|
||
### 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
|
||
git clone <your-repo-url>
|
||
cd DentalManagementMHAprilgg
|
||
```
|
||
|
||
### Step 4 — Install Node.js
|
||
|
||
Required to run the Backend and Frontend.
|
||
|
||
```sh
|
||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||
sudo apt-get install -y nodejs
|
||
|
||
# Verify
|
||
node -v # should print v20.x.x
|
||
npm -v
|
||
```
|
||
|
||
### Step 5 — Install Python
|
||
|
||
Required to run the Selenium and OCR services.
|
||
|
||
```sh
|
||
sudo apt-get install -y python3 python3-pip python3-venv
|
||
|
||
# Verify
|
||
python3 --version # should print 3.10 or higher
|
||
```
|
||
|
||
### Step 6 — Install Chrome
|
||
|
||
Required for the Selenium service to control a browser.
|
||
|
||
```sh
|
||
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -O /tmp/google-chrome-stable.deb
|
||
sudo apt install -y /tmp/google-chrome-stable.deb
|
||
|
||
# Verify
|
||
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.
|
||
|
||
### Step 7 — Install PostgreSQL
|
||
|
||
Primary database for the application.
|
||
|
||
```sh
|
||
sudo apt-get install -y postgresql postgresql-contrib
|
||
sudo systemctl enable postgresql
|
||
sudo systemctl start postgresql
|
||
|
||
# Create the database the app uses
|
||
sudo -u postgres psql -c "CREATE DATABASE dentalapp OWNER postgres;"
|
||
|
||
# Set the postgres user password to match packages/db/.env
|
||
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'mypassword';"
|
||
```
|
||
|
||
#### Enable password authentication over TCP
|
||
|
||
By default Debian uses `scram-sha-256` or `peer` auth for local connections, which blocks password login. Switch to `md5`:
|
||
|
||
```sh
|
||
sudo sed -i 's/scram-sha-256/md5/g' /etc/postgresql/*/main/pg_hba.conf
|
||
sudo systemctl restart postgresql
|
||
```
|
||
|
||
#### Verify
|
||
|
||
```sh
|
||
psql -U postgres -d dentalapp -h 127.0.0.1 -W
|
||
# Enter: mypassword
|
||
# You should see: dentalapp=#
|
||
```
|
||
|
||
> The `DATABASE_URL` in `packages/db/.env` is already set to:
|
||
> ```
|
||
> DATABASE_URL="postgresql://postgres:mypassword@localhost:5432/dentalapp"
|
||
> ```
|
||
|
||
### Step 8 — Install Redis
|
||
|
||
Used as the job queue for Selenium and OCR background tasks.
|
||
|
||
```sh
|
||
sudo apt-get install -y redis-server
|
||
sudo systemctl enable redis-server
|
||
sudo systemctl start redis-server
|
||
|
||
# Verify
|
||
redis-cli ping # should print: PONG
|
||
```
|
||
|
||
### Step 9 — Install rclone
|
||
|
||
Required for cloud backup destinations (Google Drive, Dropbox, etc.).
|
||
|
||
1. Open the file manager and navigate to the `scripts/` folder inside the project
|
||
2. Right-click `install-rclone.sh` → choose **Run as a program** or **Execute in Terminal**
|
||
3. A terminal window will open — enter your password when prompted
|
||
4. Wait for it to finish — it will print the installed version when done
|
||
|
||
To verify it installed correctly, open a terminal and run:
|
||
|
||
```sh
|
||
rclone version
|
||
```
|
||
|
||
### Step 10 — Install Node.js dependencies
|
||
|
||
```sh
|
||
npm install
|
||
```
|
||
|
||
### Step 11 — Install Python dependencies
|
||
|
||
```sh
|
||
npm run setup:python
|
||
```
|
||
|
||
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.
|
||
|
||
> 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.
|
||
|
||
```sh
|
||
npm run setup:env
|
||
```
|
||
|
||
### Step 12a — Set the Cloudflare subdomain for this office
|
||
|
||
After running `npm run setup:env` (Step 12), open the two `.env` files and fill in this office's Cloudflare subdomain.
|
||
|
||
**`apps/Frontend/.env`**
|
||
```env
|
||
VITE_CLOUDFLARE_HOST=yoursubdomain.mydentalofficemanagement.com
|
||
```
|
||
|
||
**`apps/Backend/.env`**
|
||
```env
|
||
CLOUDFLARE_HOST=yoursubdomain.mydentalofficemanagement.com
|
||
FRONTEND_URLS=http://localhost:3000,http://yoursubdomain.mydentalofficemanagement.com,https://yoursubdomain.mydentalofficemanagement.com
|
||
```
|
||
|
||
Replace `yoursubdomain` with this office's actual subdomain (e.g. `summitdentalcare`).
|
||
|
||
> If you skip this step, Cloudflare tunnel access will not work. LAN access still works without it.
|
||
|
||
### Step 13 — Set up the database
|
||
|
||
```sh
|
||
# Run migrations
|
||
npm run db:migrate
|
||
|
||
# Generate Prisma types
|
||
npm run db:generate
|
||
|
||
# Insert seed data
|
||
npm run db:seed
|
||
```
|
||
|
||
### Step 14 — Configure nginx
|
||
|
||
Install nginx:
|
||
|
||
```sh
|
||
sudo apt update
|
||
sudo apt install -y nginx
|
||
|
||
# Verify
|
||
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:
|
||
|
||
```sh
|
||
sudo cp nginx.conf /etc/nginx/sites-available/dental-app
|
||
sudo ln -sf /etc/nginx/sites-available/dental-app /etc/nginx/sites-enabled/dental-app
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
> **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."
|
||
|
||
### Step 15 — Run the app
|
||
|
||
Open two terminals:
|
||
|
||
**Terminal 1** — Backend + Frontend:
|
||
```sh
|
||
npm run dev
|
||
```
|
||
|
||
> On first boot the server automatically seeds all AI chat templates, SMS templates, and greeting messages for every user — no manual configuration needed.
|
||
|
||
**Terminal 2** — Selenium service:
|
||
```sh
|
||
cd apps/SeleniumService
|
||
.venv/bin/python3 agent.py
|
||
```
|
||
|
||
### 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.
|
||
|
||
Run this once after cloning and installing:
|
||
|
||
```sh
|
||
bash ~/Desktop/DentalManagementMH06/setup-desktop-shortcut.sh
|
||
```
|
||
|
||
Two shortcuts will appear on your desktop:
|
||
|
||
- **Dental App** — starts the app. Double-clicking it opens:
|
||
- A terminal running `npm run dev` (Backend + Frontend + Python services)
|
||
- A terminal running the Selenium service
|
||
- **Stop Dental App** — stops all services and frees all ports (5000, 5001, 5002, 5003, 3000/3001)
|
||
|
||
> 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
|
||
|
||
- [Setting up server environment](docs/server-setup.md) — the first step, to run this app in environment.
|
||
- [Development Hosts & Ports](docs/ports.md) — which app runs on which host/port, and how to configure `.env` for LAN or single-device access
|
||
|
||
---
|
||
|
||
## 🌐 Development Hosts & Ports
|
||
|
||
This section defines the default host and port used by each app/service in this turborepo. Update it whenever a new service is added or a port is changed. (Full copy also kept in [docs/ports.md](docs/ports.md).)
|
||
|
||
### Frontend (React + Vite)
|
||
- **Host:** `localhost` (default) — use `0.0.0.0` if you need LAN access (phone/other device on same Wi-Fi)
|
||
- **Port:** `3000`
|
||
- **Access URLs:**
|
||
- Local: http://localhost:3000
|
||
- LAN: `http://<your-ip>:3000` (only if `HOST=0.0.0.0`)
|
||
- **Current setup:** Frontend runs on `0.0.0.0` and is accessible via the device IP.
|
||
|
||
`.env` file (`apps/Frontend/.env`):
|
||
```env
|
||
NODE_ENV=development
|
||
HOST=0.0.0.0
|
||
PORT=3000
|
||
VITE_API_BASE_URL_BACKEND=http://192.168.1.8:5000
|
||
```
|
||
`VITE_API_BASE_URL_BACKEND` should point at the Backend's `HOST`/`PORT` as seen from the browser — use `localhost` for single-device access, or the machine's LAN IP for access from other devices.
|
||
|
||
### Backend (Express.js)
|
||
- **Host:** `0.0.0.0` (all interfaces)
|
||
- **Port:** `5000`
|
||
- **Access URL:** http://localhost:5000
|
||
- **Current setup:** Runs for all network interfaces and allows the configured `FRONTEND_URLS` via CORS.
|
||
|
||
`.env` file (`apps/Backend/.env`):
|
||
```env
|
||
NODE_ENV="development"
|
||
HOST=0.0.0.0
|
||
PORT=5000
|
||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||
```
|
||
|
||
### 🧾 Patient Data Extractor Service
|
||
- **Host:** `localhost`
|
||
- **Port:** `5001`
|
||
- **Access URL:** http://localhost:5001
|
||
|
||
### 🌐 Selenium Service
|
||
- **Host:** `localhost`
|
||
- **Port:** `5002`
|
||
- **Access URL:** http://localhost:5002
|
||
|
||
### 💳 Payment OCR Service
|
||
- **Host:** `0.0.0.0`
|
||
- **Port:** `5003`
|
||
- **Access URL:** http://localhost:5003
|
||
|
||
### 📖 Notes
|
||
- These values come from per-app `.env` files (`apps/<App>/.env`).
|
||
- `HOST` controls binding — `localhost` = loopback only, `0.0.0.0` = all interfaces.
|
||
- `PORT` controls the service's port.
|
||
- Frontend uses variables prefixed with `VITE_` for client-side access (e.g. `VITE_API_BASE_URL_BACKEND`).
|
||
- In production, ports and hosts may differ — traffic is instead routed through the [Cloudflare Tunnel](#cloudflare-tunnel-setup-remote-access-per-office).
|
||
|
||
---
|
||
|
||
## This is a Turborepo. What's inside?
|
||
|
||
### Apps and Packages
|
||
|
||
- `apps/Backend` — Express.js API server
|
||
- `apps/Frontend` — React + Vite frontend
|
||
- `apps/SeleniumService` — Python FastAPI service for browser automation (insurance eligibility, claims)
|
||
- `apps/PaymentOCRService` — Python service for payment OCR extraction
|
||
- `@repo/eslint-config` — shared ESLint configuration
|
||
- `@repo/typescript-config` — shared `tsconfig.json`s
|
||
|
||
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/) (except the Python services).
|
||
|
||
### Utilities
|
||
|
||
- [Tailwind CSS](https://tailwindcss.com/) for styles
|
||
- [TypeScript](https://www.typescriptlang.org/) for static type checking
|
||
- [ESLint](https://eslint.org/) for code linting
|
||
- [Prettier](https://prettier.io) for code formatting
|
||
|
||
---
|
||
|
||
## Cloudflare Tunnel Setup (Remote Access per Office)
|
||
|
||
This connects each office's local app to a public subdomain via a Cloudflare Tunnel — no port forwarding needed, local network access is unchanged.
|
||
|
||
**How it works:**
|
||
- Local network: other office PCs reach the app directly at `http://<machine-ip>:3000`
|
||
- Internet: anyone reaches the app via `https://<subdomain>.mydentalofficemanagement.com`
|
||
- Both paths hit the same app simultaneously with no conflict
|
||
|
||
---
|
||
|
||
### Step 1 — Add domain to Cloudflare (done once for all offices)
|
||
|
||
> Skip this step if the domain is already on Cloudflare.
|
||
|
||
1. Go to `dash.cloudflare.com` → **Add a site** → enter `mydentalofficemanagement.com` (free plan)
|
||
2. Cloudflare scans existing DNS records — review and keep them
|
||
3. Cloudflare gives you 2 nameservers (e.g. `holly.ns.cloudflare.com`, `amir.ns.cloudflare.com`)
|
||
4. Log into **Ionos** → replace the domain's nameservers with Cloudflare's two
|
||
5. Wait 10–30 min → Cloudflare emails you when active
|
||
6. DNSSEC: if not purchased on Ionos, it was never enabled — nothing to turn off
|
||
|
||
### Step 2 — Install `cloudflared` on the office PC
|
||
|
||
Run this in a terminal on the office machine:
|
||
|
||
```bash
|
||
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
|
||
sudo dpkg -i cloudflared.deb
|
||
cloudflared --version
|
||
```
|
||
|
||
### Step 3 — Authenticate with Cloudflare
|
||
|
||
```bash
|
||
cloudflared tunnel login
|
||
```
|
||
|
||
A browser window opens → click `mydentalofficemanagement.com` → terminal shows success and saves a certificate to `~/.cloudflared/cert.pem`.
|
||
|
||
### Step 4 — Create a tunnel for this office
|
||
|
||
Use a unique tunnel name per office:
|
||
|
||
```bash
|
||
cloudflared tunnel create <office-tunnel-name>
|
||
# Example: cloudflared tunnel create summit-dental-app
|
||
```
|
||
|
||
Note the **tunnel ID** (UUID) printed — you need it in Step 5.
|
||
|
||
### Step 5 — Create the config file
|
||
|
||
```bash
|
||
sudo mkdir -p /etc/cloudflared
|
||
sudo nano /etc/cloudflared/config.yml
|
||
```
|
||
|
||
Paste (replace tunnel ID, credentials path, and subdomain for this office):
|
||
|
||
```yaml
|
||
tunnel: <tunnel-ID>
|
||
credentials-file: /home/ee/.cloudflared/<tunnel-ID>.json
|
||
|
||
ingress:
|
||
- hostname: <subdomain>.mydentalofficemanagement.com
|
||
service: http://localhost:3000
|
||
- service: http_status:404
|
||
```
|
||
|
||
Save: `Ctrl+O` → Enter → `Ctrl+X`.
|
||
|
||
### Step 6 — Route DNS for this office's subdomain
|
||
|
||
```bash
|
||
cloudflared tunnel route dns <office-tunnel-name> <subdomain>.mydentalofficemanagement.com
|
||
```
|
||
|
||
### Step 7 — Install as a system service (auto-starts on boot)
|
||
|
||
```bash
|
||
sudo cloudflared service install
|
||
sudo systemctl enable cloudflared
|
||
sudo systemctl start cloudflared
|
||
sudo systemctl status cloudflared
|
||
```
|
||
|
||
### Step 8 — Allow the subdomain in Vite
|
||
|
||
In `apps/Frontend/vite.config.js`, add the subdomain to `server.allowedHosts` so Vite does not block external requests.
|
||
|
||
### Step 9 — Allow the subdomain in backend CORS
|
||
|
||
In `apps/Backend`, add the subdomain URL to the allowed CORS origins so login and API calls work from the public URL.
|
||
|
||
---
|
||
|
||
### Example — Adding Summit Dental Care
|
||
|
||
**Office subdomain:** `summitdentalcare.mydentalofficemanagement.com`
|
||
|
||
**Step 1:** Already done (domain is on Cloudflare).
|
||
|
||
**Step 2:** Install `cloudflared` on Summit Dental's PC.
|
||
|
||
**Step 3:** Run `cloudflared tunnel login` on Summit Dental's PC.
|
||
|
||
**Step 4:**
|
||
```bash
|
||
cloudflared tunnel create summit-dental-app
|
||
# Example output: Created tunnel summit-dental-app with id a1b2c3d4-...
|
||
```
|
||
|
||
**Step 5 — `/etc/cloudflared/config.yml`:**
|
||
```yaml
|
||
tunnel: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||
credentials-file: /home/ee/.cloudflared/a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json
|
||
|
||
ingress:
|
||
- hostname: summitdentalcare.mydentalofficemanagement.com
|
||
service: http://localhost:3000
|
||
- service: http_status:404
|
||
```
|
||
|
||
**Step 6:**
|
||
```bash
|
||
cloudflared tunnel route dns summit-dental-app summitdentalcare.mydentalofficemanagement.com
|
||
```
|
||
|
||
**Step 7:**
|
||
```bash
|
||
sudo cloudflared service install
|
||
sudo systemctl enable cloudflared
|
||
sudo systemctl start cloudflared
|
||
```
|
||
|
||
**Step 8:** Add `summitdentalcare.mydentalofficemanagement.com` to `allowedHosts` in `vite.config.js`.
|
||
|
||
**Step 9:** Add `https://summitdentalcare.mydentalofficemanagement.com` to backend CORS allowed origins.
|
||
|
||
---
|
||
|
||
### Variant — Tunnel scoped to Twilio/Telnyx 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 webhooks — Twilio's (dial pad outbound
|
||
calls only — see [Twilio In-Browser Calling Setup](#twilio-in-browser-calling-setup-dial-pad)
|
||
below) and Telnyx's (everything else — see [Telnyx Setup](#telnyx-setup) below) — point the
|
||
tunnel at nginx's port-80 block instead, which only forwards `/api/twilio/*` and `/api/telnyx/*`
|
||
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/Telnyx 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/Telnyx
|
||
webhook routes.
|
||
|
||
---
|
||
|
||
### Multi-office overview
|
||
|
||
Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflict because each PC is a separate machine.
|
||
|
||
| Office | Local access | Public access | Tunnel name |
|
||
|---|---|---|---|
|
||
| Community Dentists of Lowell | `http://192.168.1.236:3000` | `https://communitydentistsoflowell.mydentalofficemanagement.com` | `dental-app` |
|
||
| Summit Dental Care | `http://<its-ip>:3000` | `https://summitdentalcare.mydentalofficemanagement.com` | `summit-dental-app` |
|
||
| Next office | `http://<its-ip>:3000` | `https://<subdomain>.mydentalofficemanagement.com` | `<office>-app` |
|
||
|
||
---
|
||
|
||
## 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)
|
||
|
||
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.
|
||
|
||
### One-time Twilio Console setup (required before first call)
|
||
|
||
1. Go to **Twilio Console → Explore Products → Voice → TwiML Apps**
|
||
2. Click **Create new TwiML App**
|
||
3. Set the **Voice Request URL** to:
|
||
```
|
||
https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice-browser
|
||
```
|
||
4. Save — copy the **TwiML App SID** (starts with `AP`)
|
||
5. In the dental app, go to **Settings → Twilio Settings → TwiML App SID** → paste the SID and save
|
||
|
||
Once saved, the dial pad on the Patient Connection page is fully functional. The staff member's browser mic/speaker is used for the call; the patient receives a normal phone call from the office Twilio number.
|
||
|
||
---
|
||
|
||
## Telnyx Setup
|
||
|
||
Telnyx handles everything except the in-browser dial pad above: outbound/inbound SMS (including
|
||
the AI text conversation), inbound voicemail, and both staff-triggered and AI-driven outbound
|
||
calls. It uses **TeXML** (Telnyx's TwiML-compatible voice API), which is why the webhook XML looks
|
||
just like the Twilio webhooks above.
|
||
|
||
This reuses the same Cloudflare Tunnel already set up for Twilio above (see
|
||
[Cloudflare Tunnel Setup](#cloudflare-tunnel-setup-remote-access-per-office)) — no new tunnel or
|
||
DNS record needed, Telnyx just hits the same public hostname on a different path
|
||
(`/api/telnyx/*` instead of `/api/twilio/*`). If you're using the
|
||
[tunnel-scoped-to-webhooks variant](#variant--tunnel-scoped-to-twiliotelnyx-webhooks-only), make
|
||
sure `nginx.conf`'s port-80 block includes the `/api/telnyx/` `location` block — Telnyx webhooks
|
||
will silently 403 in production without it.
|
||
|
||
### Step 1 — Buy/assign a phone number for this office
|
||
|
||
In the [Telnyx Mission Control Portal](https://portal.telnyx.com), buy or port a number for this
|
||
office (each office needs its own Telnyx number, separate from its Twilio dial-pad number).
|
||
|
||
### Step 2 — Create a Messaging Profile (for SMS)
|
||
|
||
1. **Messaging → Messaging Profiles → Create Messaging Profile**
|
||
2. Attach this office's phone number to the profile
|
||
3. Set the **Inbound Webhook URL** to:
|
||
```
|
||
https://<office>.mydentalofficemanagement.com/api/telnyx/webhook/sms
|
||
```
|
||
4. Note the **Messaging Profile ID** if your account requires one for sending (some number types don't)
|
||
|
||
### Step 3 — Create a TeXML Application (for calls)
|
||
|
||
1. **Voice → TeXML Applications → Create TeXML Application**
|
||
2. Set the **Voice Webhook URL** to:
|
||
```
|
||
https://<office>.mydentalofficemanagement.com/api/telnyx/webhook/voice
|
||
```
|
||
3. Attach this office's phone number to the application
|
||
4. Note the **Application ID** (also called Connection ID)
|
||
|
||
### Step 4 — Get an API Key and Account SID
|
||
|
||
**API Keys & Credentials** in the portal → create an API Key. The **Account SID** (separate from
|
||
the API Key, needed for placing outbound calls) is shown in your account/organization settings.
|
||
|
||
### Step 5 — Enter credentials in the app
|
||
|
||
Go to **Settings → Telnyx Settings** and fill in: API Key, Account SID, Phone Number, TeXML
|
||
Application ID, Messaging Profile ID (if applicable), and an optional Voicemail Greeting → Save.
|
||
|
||
### Step 6 — Webhook signature verification (optional, recommended)
|
||
|
||
Telnyx signs webhooks with Ed25519. If you want inbound webhook signatures verified (rejecting
|
||
forged requests to your webhook URLs), copy your account's **Public Key** from the portal into
|
||
the Webhook Public Key field in Telnyx Settings.
|
||
|
||
---
|
||
|
||
## License Key Generator
|
||
|
||
The license key generator is a private tool that lives only on your dev PC. Use it to generate a new license key for any office every 3 months.
|
||
|
||
**Location:** `/home/ff/Desktop/LicenseGenerator/`
|
||
|
||
**Generate a 3-month key (default):**
|
||
```bash
|
||
node /home/ff/Desktop/LicenseGenerator/generate-license.js
|
||
```
|
||
|
||
**Generate a key with a custom duration:**
|
||
```bash
|
||
node /home/ff/Desktop/LicenseGenerator/generate-license.js --months=6
|
||
```
|
||
|
||
**Example output:**
|
||
```
|
||
=== Dental App License Key ===
|
||
|
||
License Key: DENTAL-8ED7AAEF3E0CA008D98CC1E0-2026-08-26
|
||
Expires: 2026-08-26
|
||
Duration: 3 month(s)
|
||
|
||
Paste this key into the Activation page in the app.
|
||
```
|
||
|
||
**Workflow:**
|
||
1. Office pays renewal fee
|
||
2. Run the generator script above
|
||
3. Copy the License Key
|
||
4. Paste it into the **Activation** page in the app (via RustDesk or in person)
|
||
5. Record the key, office name, expiry, and payment in your records
|
||
|
||
**Important — `secret.key`:**
|
||
- `/home/ff/Desktop/LicenseGenerator/secret.key` is the private secret used to sign all keys
|
||
- Back it up on a USB drive or password manager
|
||
- If lost, all existing keys become invalid and new keys must be issued to all offices
|
||
|
||
---
|
||
|
||
## Network Backup Setup (PC-to-PC Sync)
|
||
|
||
Two PCs running the app can be linked so the backup PC automatically pulls a fresh copy of the main PC's database every night. The config survives database restores because it is stored in local files, not in the database.
|
||
|
||
**Prerequisites:** Both PCs must be on the same local network (e.g. connected to the same router or switch). Set a static IP on the main PC so its address never changes after a reboot (set in the OS network settings, not in the router).
|
||
|
||
### On PC1 (main server)
|
||
|
||
1. Open the app → **Database Management** → **Network Backup**
|
||
2. Under **This Machine's Backup Key**, click the eye icon to reveal the key
|
||
3. Click the copy button to copy it
|
||
|
||
### On PC2 (backup PC)
|
||
|
||
1. Open the app → **Database Management** → **Network Backup**
|
||
2. Under **Sync from Another PC**:
|
||
- Toggle **Enable daily sync** on
|
||
- Select the hour you want the sync to run (e.g. `12:00 AM (midnight)`)
|
||
- Enter PC1's URL in the **Source PC URL** field, e.g. `http://192.168.0.94:3000`
|
||
- Paste PC1's key into the **Source PC API Key** field
|
||
3. Click **Save Settings**
|
||
4. Click **Sync Now** to test — PC2's database will be replaced with PC1's
|
||
|
||
After a successful test, the sync will run automatically at the scheduled hour every day.
|
||
|
||
**Notes:**
|
||
- The API key and sync config are stored in `apps/Backend/network-backup-key.json` and `apps/Backend/network-sync-config.json` — they survive database restores
|
||
- If you regenerate PC1's key, you must update it on PC2 as well
|
||
- The sync is one-way: PC2 always mirrors PC1; PC1 is never modified
|
||
|
||
---
|
||
|
||
## Compile & Deploy to New PC
|
||
|
||
### The Plan
|
||
|
||
On your dev PC, run `npm run build` — this compiles the Frontend (React → `dist/`) and Backend (TypeScript → JS). The result is placed in a new deploy folder that contains no source code, then copied via USB to the office PC.
|
||
|
||
```
|
||
/home/ff/Desktop/DentalManagement-Deploy/ ← copy this to the new PC (no source code inside)
|
||
```
|
||
|
||
### New Office PC — One-time Setup (manual)
|
||
|
||
| Step | How |
|
||
|---|---|
|
||
| Install Linux, Chrome, PostgreSQL, Python, Node | Manually |
|
||
| Install RustDesk | Manually |
|
||
| Paste the deploy folder from USB | You |
|
||
| Run `setup.sh` | You |
|
||
| Enter license key in Activation page | You |
|
||
|
||
### Updates (after bug fixes)
|
||
|
||
Build on dev PC → copy only the updated `dist/` folders via USB to the office PC (not the whole folder):
|
||
|
||
```
|
||
apps/Backend/dist/ → /home/ff/Desktop/DentalManagement-Deploy/apps/Backend/dist/
|
||
apps/Frontend/dist/ → /home/ff/Desktop/DentalManagement-Deploy/apps/Frontend/dist/
|
||
```
|
||
|
||
### License System
|
||
|
||
- All PCs (including your dev PC) need a license key every 3 months
|
||
- Keys have no machine ID — just an expiry date + your HMAC signature
|
||
- Generate a key: `node /home/ff/Desktop/LicenseGenerator/generate-license.js`
|
||
- Key format: `DENTAL-{24-char-signature}-YYYY-MM-DD`
|
||
- `secret.key` must be backed up — losing it invalidates all existing keys
|
||
|
||
### Free vs Premium
|
||
|
||
| Tier | Features |
|
||
|---|---|
|
||
| **Free** | MassHealth Eligibility, MassHealth Claim, Documents, Payments, Database Backups, Reports |
|
||
| **Premium (license required)** | CCA, DDMA, United, Tufts Eligibility & Claims, Pre-Auths, AI SMS |
|
||
|
||
---
|
||
|
||
## Claude Code Memory
|
||
|
||
Claude Code (the AI assistant used to build this project) stores its memory locally on the PC. This memory contains project context, architecture decisions, feature history, and working preferences — allowing Claude to pick up where it left off in new sessions.
|
||
|
||
**Memory location:**
|
||
```
|
||
/home/ff/.claude/projects/-home-ff-Desktop-DentalManagementMH06/memory/
|
||
```
|
||
|
||
**To copy to a new PC:**
|
||
|
||
1. On the old PC, copy the memory folder:
|
||
```bash
|
||
cp -r /home/ff/.claude/projects/-home-ff-Desktop-DentalManagementMH06/memory/ /media/usb/claude-memory-backup/
|
||
```
|
||
|
||
2. On the new PC, recreate the directory and paste:
|
||
```bash
|
||
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/
|
||
```
|
||
|
||
---
|
||
|
||
## 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.
|