Merge branch 'dev-emile'
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ dist/
|
|||||||
|
|
||||||
# env
|
# env
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
*chrome_profile_ddma*
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
apps/SeleniumService/ddma_browser_manager.py
Normal file
102
apps/SeleniumService/ddma_browser_manager.py
Normal 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
|
||||||
@@ -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,10 +148,20 @@ 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(
|
||||||
@@ -169,6 +180,11 @@ async def start_ddma_run(sid: str, data: dict, url: str):
|
|||||||
submit_btn.click()
|
submit_btn.click()
|
||||||
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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
## 4 — Restore 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 it’s `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 Prisma’s 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user