Merge branch 'dev-emile'

This commit is contained in:
2026-01-11 20:48:04 +05:30
10 changed files with 326 additions and 138 deletions

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ dist/
# env # env
*.env *.env
*chrome_profile_ddma*

View File

@@ -15,7 +15,13 @@ const router = Router();
router.post("/", async (req: Request, res: Response): Promise<any> => { router.post("/", async (req: Request, res: Response): Promise<any> => {
try { try {
const validatedData = staffCreateSchema.parse(req.body); const userId = req.user!.id; // from auth middleware
const validatedData = staffCreateSchema.parse({
...req.body,
userId,
});
const newStaff = await storage.createStaff(validatedData); const newStaff = await storage.createStaff(validatedData);
res.status(200).json(newStaff); res.status(200).json(newStaff);
} catch (error) { } catch (error) {

View File

@@ -4,7 +4,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
async function throwIfResNotOk(res: Response) { async function throwIfResNotOk(res: Response) {
if (!res.ok) { if (!res.ok) {
if (res.status === 401 || res.status === 403) { if (res.status === 401) {
localStorage.removeItem("token"); localStorage.removeItem("token");
if (!window.location.pathname.startsWith("/auth")) { if (!window.location.pathname.startsWith("/auth")) {
window.location.href = "/auth"; window.location.href = "/auth";

View File

@@ -3,6 +3,6 @@
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "pip install -r requirements.txt", "postinstall": "pip install -r requirements.txt",
"dev": "python main.py" "dev": "python3 main.py"
} }
} }

View File

@@ -3,6 +3,6 @@
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "pip install -r requirements.txt", "postinstall": "pip install -r requirements.txt",
"dev": "python main.py" "dev": "python3 main.py"
} }
} }

View File

@@ -0,0 +1,102 @@
"""
Minimal browser manager for DDMA - only handles persistent profile and keeping browser alive.
Does NOT modify any login/OTP logic.
"""
import os
import threading
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
class DDMABrowserManager:
"""
Singleton that manages a persistent Chrome browser instance.
- Uses --user-data-dir for persistent profile (device trust tokens, cookies)
- Keeps browser alive between patient runs
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._driver = None
cls._instance.profile_dir = os.path.abspath("chrome_profile_ddma")
cls._instance.download_dir = os.path.abspath("seleniumDownloads")
os.makedirs(cls._instance.profile_dir, exist_ok=True)
os.makedirs(cls._instance.download_dir, exist_ok=True)
return cls._instance
def get_driver(self, headless=False):
"""Get or create the persistent browser instance."""
with self._lock:
if self._driver is None:
print("[BrowserManager] Driver is None, creating new driver")
self._create_driver(headless)
elif not self._is_alive():
print("[BrowserManager] Driver not alive, recreating")
self._create_driver(headless)
else:
print("[BrowserManager] Reusing existing driver")
return self._driver
def _is_alive(self):
"""Check if browser is still responsive."""
try:
url = self._driver.current_url
print(f"[BrowserManager] Driver alive, current URL: {url[:50]}...")
return True
except Exception as e:
print(f"[BrowserManager] Driver not alive: {e}")
return False
def _create_driver(self, headless=False):
"""Create browser with persistent profile."""
if self._driver:
try:
self._driver.quit()
except:
pass
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
# Persistent profile - THIS IS THE KEY for device trust
options.add_argument(f"--user-data-dir={self.profile_dir}")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True
}
options.add_experimental_option("prefs", prefs)
service = Service(ChromeDriverManager().install())
self._driver = webdriver.Chrome(service=service, options=options)
self._driver.maximize_window()
def quit_driver(self):
"""Quit browser (only call on shutdown)."""
with self._lock:
if self._driver:
try:
self._driver.quit()
except:
pass
self._driver = None
# Singleton accessor
_manager = None
def get_browser_manager():
global _manager
if _manager is None:
_manager = DDMABrowserManager()
return _manager

View File

@@ -60,14 +60,8 @@ async def cleanup_session(sid: str, message: str | None = None):
except Exception: except Exception:
pass pass
# Attempt to quit driver (may already be dead) # NOTE: Do NOT quit driver - keep browser alive for next patient
driver = s.get("driver") # Browser manager handles the persistent browser instance
if driver:
try:
driver.quit()
except Exception:
# ignore errors from quit (session already gone)
pass
finally: finally:
# Remove session entry from map # Remove session entry from map
@@ -126,8 +120,15 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid, s["message"]) await cleanup_session(sid, s["message"])
return {"status": "error", "message": s["message"]} return {"status": "error", "message": s["message"]}
# Already logged in - session persisted from profile, skip to step1
if isinstance(login_result, str) and login_result == "ALREADY_LOGGED_IN":
print("[start_ddma_run] Session persisted - skipping OTP")
s["status"] = "running"
s["message"] = "Session persisted"
# Continue to step1 below
# OTP required path # OTP required path
if isinstance(login_result, str) and login_result == "OTP_REQUIRED": elif isinstance(login_result, str) and login_result == "OTP_REQUIRED":
s["status"] = "waiting_for_otp" s["status"] = "waiting_for_otp"
s["message"] = "OTP required for login" s["message"] = "OTP required for login"
s["last_activity"] = time.time() s["last_activity"] = time.time()
@@ -147,11 +148,21 @@ async def start_ddma_run(sid: str, data: dict, url: str):
await cleanup_session(sid) await cleanup_session(sid)
return {"status": "error", "message": "OTP missing after event"} return {"status": "error", "message": "OTP missing after event"}
# Submit OTP in the same Selenium window # Submit OTP - check if it's in a popup window
try: try:
driver = s["driver"] driver = s["driver"]
wait = WebDriverWait(driver, 30) wait = WebDriverWait(driver, 30)
# Check if there's a popup window and switch to it
original_window = driver.current_window_handle
all_windows = driver.window_handles
if len(all_windows) > 1:
for window in all_windows:
if window != original_window:
driver.switch_to.window(window)
print(f"[OTP] Switched to popup window for OTP entry")
break
otp_input = wait.until( otp_input = wait.until(
EC.presence_of_element_located( EC.presence_of_element_located(
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]") (By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
@@ -170,6 +181,11 @@ async def start_ddma_run(sid: str, data: dict, url: str):
except Exception: except Exception:
otp_input.send_keys("\n") otp_input.send_keys("\n")
# Wait for verification and switch back to main window if needed
await asyncio.sleep(2)
if len(driver.window_handles) > 0:
driver.switch_to.window(driver.window_handles[0])
s["status"] = "otp_submitted" s["status"] = "otp_submitted"
s["last_activity"] = time.time() s["last_activity"] = time.time()
await asyncio.sleep(0.5) await asyncio.sleep(0.5)

View File

@@ -1,15 +1,14 @@
from selenium import webdriver from selenium.common.exceptions import TimeoutException
from selenium.common.exceptions import WebDriverException, TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
import time import time
import os import os
import base64 import base64
from ddma_browser_manager import get_browser_manager
class AutomationDeltaDentalMAEligibilityCheck: class AutomationDeltaDentalMAEligibilityCheck:
def __init__(self, data): def __init__(self, data):
self.headless = False self.headless = False
@@ -24,31 +23,139 @@ class AutomationDeltaDentalMAEligibilityCheck:
self.massddma_username = self.data.get("massddmaUsername", "") self.massddma_username = self.data.get("massddmaUsername", "")
self.massddma_password = self.data.get("massddmaPassword", "") self.massddma_password = self.data.get("massddmaPassword", "")
self.download_dir = os.path.abspath("seleniumDownloads") # Use browser manager's download dir
self.download_dir = get_browser_manager().download_dir
os.makedirs(self.download_dir, exist_ok=True) os.makedirs(self.download_dir, exist_ok=True)
def config_driver(self): def config_driver(self):
options = webdriver.ChromeOptions() # Use persistent browser from manager (keeps device trust tokens)
if self.headless: self.driver = get_browser_manager().get_driver(self.headless)
options.add_argument("--headless")
# Add PDF download preferences
prefs = {
"download.default_directory": self.download_dir,
"plugins.always_open_pdf_externally": True,
"download.prompt_for_download": False,
"download.directory_upgrade": True
}
options.add_experimental_option("prefs", prefs)
s = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=s, options=options)
self.driver = driver
def login(self, url): def login(self, url):
wait = WebDriverWait(self.driver, 30) wait = WebDriverWait(self.driver, 30)
try: try:
# First check if we're already on a logged-in page (from previous run)
try:
current_url = self.driver.current_url
print(f"[login] Current URL: {current_url}")
# Check if we're on any logged-in page (dashboard, member pages, etc.)
logged_in_patterns = ["member", "dashboard", "eligibility", "search", "patients"]
is_logged_in_url = any(pattern in current_url.lower() for pattern in logged_in_patterns)
if is_logged_in_url and "onboarding" not in current_url.lower():
print(f"[login] Already on logged-in page - skipping login entirely")
# Navigate directly to member search if not already there
if "member" not in current_url.lower():
# Try to find a link to member search or just check for search input
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[login] Found member search input - returning ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
# Try navigating to members page
members_url = "https://providers.deltadentalma.com/members"
print(f"[login] Navigating to members page: {members_url}")
self.driver.get(members_url)
time.sleep(2)
# Verify we have the member search input
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
print("[login] Member search found - ALREADY_LOGGED_IN")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Could not find member search, will try login")
except Exception as e:
print(f"[login] Error checking current state: {e}")
# Navigate to login URL
self.driver.get(url) self.driver.get(url)
time.sleep(2) # Wait for page to load and any redirects
# Check if we got redirected to member search (session still valid)
try:
current_url = self.driver.current_url
print(f"[login] URL after navigation: {current_url}")
if "onboarding" not in current_url.lower():
member_search = WebDriverWait(self.driver, 3).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
if member_search:
print("[login] Session valid - skipping login")
return "ALREADY_LOGGED_IN"
except TimeoutException:
print("[login] Proceeding with login")
# Dismiss any "Authentication flow continued in another tab" modal
modal_dismissed = False
try:
ok_button = WebDriverWait(self.driver, 3).until(
EC.element_to_be_clickable((By.XPATH, "//button[normalize-space(text())='Ok' or normalize-space(text())='OK']"))
)
ok_button.click()
print("[login] Dismissed authentication modal")
modal_dismissed = True
time.sleep(2)
# Check if a popup window opened for authentication
all_windows = self.driver.window_handles
print(f"[login] Windows after modal dismiss: {len(all_windows)}")
if len(all_windows) > 1:
# Switch to the auth popup
original_window = self.driver.current_window_handle
for window in all_windows:
if window != original_window:
self.driver.switch_to.window(window)
print(f"[login] Switched to auth popup window")
break
# Look for OTP input in the popup
try:
otp_candidate = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code') or contains(@aria-label,'Verification code')]")
)
)
if otp_candidate:
print("[login] OTP input found in popup -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
print("[login] No OTP in popup, checking main window")
self.driver.switch_to.window(original_window)
except TimeoutException:
pass # No modal present
# If modal was dismissed but no popup, page might have changed - wait and check
if modal_dismissed:
time.sleep(2)
# Check if we're now on member search page (already authenticated)
try:
member_search = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]'))
)
if member_search:
print("[login] Already authenticated after modal dismiss")
return "ALREADY_LOGGED_IN"
except TimeoutException:
pass
# Try to fill login form
try:
email_field = WebDriverWait(self.driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//input[@name='username' and @type='text']"))
)
except TimeoutException:
print("[login] Could not find login form - page may have changed")
return "ERROR: Login form not found"
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']"))) email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']")))
email_field.clear() email_field.clear()
email_field.send_keys(self.massddma_username) email_field.send_keys(self.massddma_username)
@@ -226,6 +333,15 @@ class AutomationDeltaDentalMAEligibilityCheck:
pass pass
print("Screenshot saved at:", screenshot_path) print("Screenshot saved at:", screenshot_path)
# Close the browser window after screenshot (session preserved in profile)
try:
from ddma_browser_manager import get_browser_manager
get_browser_manager().quit_driver()
print("[step2] Browser closed - session preserved in profile")
except Exception as e:
print(f"[step2] Error closing browser: {e}")
output = { output = {
"status": "success", "status": "success",
"eligibility": eligibilityText, "eligibility": eligibilityText,
@@ -254,14 +370,7 @@ class AutomationDeltaDentalMAEligibilityCheck:
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}") print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
finally: # NOTE: Do NOT quit driver here - keep browser alive for next patient
# Keep your existing quit behavior; if you want the driver to remain open for further
# actions, remove or change this.
if self.driver:
try:
self.driver.quit()
except Exception:
pass
def main_workflow(self, url): def main_workflow(self, url):
try: try:
@@ -289,10 +398,4 @@ class AutomationDeltaDentalMAEligibilityCheck:
"status": "error", "status": "error",
"message": e "message": e
} }
# NOTE: Do NOT quit driver - keep browser alive for next patient
finally:
try:
if self.driver:
self.driver.quit()
except Exception:
pass

View File

@@ -26,7 +26,14 @@ PGPASSWORD='mypassword' createdb -U postgres -h localhost -O postgres dentalapp
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -j 4 /tmp/dental_dump_dir PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -j 4 /tmp/dental_dump_dir
# (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch) # (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch)
``` ```
# 1.2 — (If needed) fix postgres user password / auth
If `createdb` or `pg_restore` fails with password auth:
```bash
# set postgres role password
sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';"
```
--- ---
# 2 — Confirm DB has tables # 2 — Confirm DB has tables
@@ -37,120 +44,72 @@ PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "\dt"
--- ---
# 3 — (If needed) fix postgres user password / auth ## 3 — Let Prisma create the schema (RUN ONCE)
If `createdb` or `pg_restore` fails with password auth:
```bash ```bash
# set postgres role password npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts
sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';"
```
---
# 4 — Inspect `_prisma_migrations` in restored DB
```bash
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT id, migration_name, finished_at FROM _prisma_migrations ORDER BY finished_at;"
``` ```
**Why:** the backup included `_prisma_migrations` from the original PC, which causes Prisma to detect "missing migrations" locally. Expected:
```
Your database is now in sync with your schema.
```
This step:
* Creates tables, enums, indexes, FKs
* Creates `_prisma_migrations`
* Creates the first local migration
--- ---
# 5(If present) remove old Prisma bookkeeping from DB ## 4Restore DATA ONLY from backup
> We prefer to *not* use the old history from PC1 and create a fresh baseline on PC2. ```bash
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp -Fd /tmp/dental_dump_dir --data-only --disable-triggers
```
⚠️ This will also restore old `_prisma_migrations` rows — we fix that next.
---
## 5 — Remove old Prisma bookkeeping from backup
```bash ```bash
# truncate migration records (bookkeeping only)
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "TRUNCATE TABLE _prisma_migrations;" PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "TRUNCATE TABLE _prisma_migrations;"
# verify
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT count(*) FROM _prisma_migrations;"
``` ```
**Why:** remove migration rows copied from PC1 so we can register a clean baseline for PC2. This:
* Does NOT touch data
* Does NOT touch schema
* Removes old PC1 migration history
--- ---
# 6 — Create a migrations directory + baseline migration folder (bookkeeping) ## 6 — Re-register the current migration as applied (CRITICAL STEP)
From project root (where `prisma/schema.prisma` lives — in your repo its `packages/db/prisma/schema.prisma`): Replace the migration name with the one created in step 3
(example: `20260103121811`).
```bash ```bash
# create migrations dir if missing (adjust path if your prisma folder is elsewhere) npx prisma migrate resolve --applied 20260103121811 --config=packages/db/prisma/prisma.config.ts
mkdir -p packages/db/prisma/migrations
# create a timestamped folder (example uses date command)
folder="packages/db/prisma/migrations/$(date +%Y%m%d%H%M%S)_init"
mkdir -p "$folder"
# create placeholder migration files
cat > "$folder/migration.sql" <<'SQL'
-- Baseline migration for PC2 (will be replaced with real SQL)
SQL
cat > "$folder/README.md" <<'TXT'
Initial baseline migration created on PC2.
This is intended as a bookkeeping-only migration.
TXT
# confirm folder name
ls -la packages/db/prisma/migrations
``` ```
**Why:** Prisma requires at least one migration file locally as a baseline. This tells Prisma:
> “Yes, this migration already created the schema.”
--- ---
# 7 — Generate the full baseline SQL (so Prismas expected schema matches DB) ## 7 — Verify Prisma state
Use Prisma `migrate diff` to produce SQL that creates your current schema, writing it into the migration file you created:
```bash ```bash
# replace the folder name with the real one printed above, e.g. 20251203101323_init npx prisma migrate status --config=packages/db/prisma/prisma.config.ts
npx prisma migrate diff \
--from-empty \
--to-schema-datamodel=packages/db/prisma/schema.prisma \
--script > packages/db/prisma/migrations/20251203101323_init/migration.sql
``` ```
If your shell complains about line breaks, run the whole command on one line (as above). Expected:
**Fallback (if `migrate diff` not available):**
```bash
PGPASSWORD='mypassword' pg_dump -U postgres -h localhost -s dentalapp > /tmp/dental_schema.sql
cp /tmp/dental_schema.sql packages/db/prisma/migrations/20251203101323_init/migration.sql
```
**Why:** this makes the migration file contain CREATE TABLE / CREATE TYPE / FK / INDEX statements matching the DB so Prisma's expected schema = actual DB.
---
# 8 — Register the baseline migration with Prisma (using the exact env/schema your scripts use)
Important: use same env file and `--schema` (and `--config` if used) that your npm script uses. Example for your repo:
```bash
# from repo root, mark applied for the migrations folder we created
npx dotenv -e packages/db/.env -- npx prisma migrate resolve --applied "20251203101323_init" --schema=packages/db/prisma/schema.prisma
```
**Why:** record the baseline in `_prisma_migrations` with the checksum matching the `migration.sql` file.
---
# 9 — Verify status and generate client
```bash
# same env/schema flags
npx dotenv -e packages/db/.env -- npx prisma migrate status --schema=packages/db/prisma/schema.prisma
npx dotenv -e packages/db/.env -- npx prisma generate --schema=packages/db/prisma/schema.prisma
```
You should see:
``` ```
1 migration found in prisma/migrations 1 migration found in prisma/migrations

View File

@@ -100,7 +100,7 @@ model Appointment {
model Staff { model Staff {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int? userId Int
name String name String
email String? email String?
role String // e.g., "Dentist", "Hygienist", "Assistant" role String // e.g., "Dentist", "Hygienist", "Assistant"