Merge branch 'develop'
This commit is contained in:
@@ -53,6 +53,7 @@ i.e apps/PatientDataExtractorService/package.json.
|
|||||||
|
|
||||||
## 📖 Developer Documentation
|
## 📖 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
|
- [Development Hosts & Ports](docs/ports.md) — which app runs on which host/port
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ NODE_ENV="development"
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=5000
|
PORT=5000
|
||||||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||||
|
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||||
JWT_SECRET = 'dentalsecret'
|
JWT_SECRET = 'dentalsecret'
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zod-validation-error": "^3.4.0"
|
"zod-validation-error": "^3.4.0"
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
|
import fs from "fs";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { NotificationTypes } from "@repo/db/types";
|
import { NotificationTypes } from "@repo/db/types";
|
||||||
|
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Daily cron job to check if users haven't backed up in 7 days
|
* Daily cron job to check if users haven't backed up in 7 days
|
||||||
* Creates a backup notification if overdue
|
* Creates a backup notification if overdue
|
||||||
*/
|
*/
|
||||||
export const startBackupCron = () => {
|
export const startBackupCron = () => {
|
||||||
cron.schedule("0 9 * * *", async () => {
|
cron.schedule("0 2 * * *", async () => {
|
||||||
console.log("🔄 Running daily backup check...");
|
// Every calendar days, at 2 AM
|
||||||
|
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||||
|
|
||||||
|
console.log("🔄 Running backup check...");
|
||||||
|
|
||||||
const userBatchSize = 100;
|
const userBatchSize = 100;
|
||||||
let userOffset = 0;
|
let userOffset = 0;
|
||||||
@@ -23,7 +28,52 @@ export const startBackupCron = () => {
|
|||||||
if (user.id == null) {
|
if (user.id == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(user.id);
|
||||||
const lastBackup = await storage.getLastBackup(user.id);
|
const lastBackup = await storage.getLastBackup(user.id);
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// CASE 1: Destination exists → auto backup
|
||||||
|
// ==============================
|
||||||
|
if (destination) {
|
||||||
|
if (!fs.existsSync(destination.path)) {
|
||||||
|
await storage.createNotification(
|
||||||
|
user.id,
|
||||||
|
"BACKUP",
|
||||||
|
"❌ Automatic backup failed: external drive not connected."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filename = `dental_backup_${Date.now()}.zip`;
|
||||||
|
|
||||||
|
await backupDatabaseToPath({
|
||||||
|
destinationPath: destination.path,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.createBackup(user.id);
|
||||||
|
await storage.deleteNotificationsByType(user.id, "BACKUP");
|
||||||
|
|
||||||
|
console.log(`✅ Auto backup successful for user ${user.id}`);
|
||||||
|
continue;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Auto backup failed for user ${user.id}`, err);
|
||||||
|
|
||||||
|
await storage.createNotification(
|
||||||
|
user.id,
|
||||||
|
"BACKUP",
|
||||||
|
"❌ Automatic backup failed. Please check your backup destination."
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// CASE 2: No destination → fallback to reminder
|
||||||
|
// ==============================
|
||||||
|
|
||||||
const daysSince = lastBackup?.createdAt
|
const daysSince = lastBackup?.createdAt
|
||||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||||
(1000 * 60 * 60 * 24)
|
(1000 * 60 * 60 * 24)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import app from "./app";
|
import app from "./app";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import http from "http";
|
||||||
|
import { initSocket } from "./socket";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -11,7 +13,13 @@ const NODE_ENV = (
|
|||||||
const HOST = process.env.HOST || "0.0.0.0";
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
const PORT = Number(process.env.PORT) || 5000;
|
const PORT = Number(process.env.PORT) || 5000;
|
||||||
|
|
||||||
const server = app.listen(PORT, HOST, () => {
|
// HTTP server from express app
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
// Initialize socket.io on this server
|
||||||
|
initSocket(server);
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,17 +126,28 @@ router.post(
|
|||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupTitle = "Claims";
|
// Allowed keys as a literal tuple to derive a union type
|
||||||
|
const allowedKeys = [
|
||||||
|
"INSURANCE_CLAIM",
|
||||||
|
"INSURANCE_CLAIM_PREAUTH",
|
||||||
|
] as const;
|
||||||
|
type GroupKey = (typeof allowedKeys)[number];
|
||||||
|
const isGroupKey = (v: any): v is GroupKey =>
|
||||||
|
(allowedKeys as readonly string[]).includes(v);
|
||||||
|
|
||||||
// allowed keys
|
if (!isGroupKey(groupTitleKey)) {
|
||||||
const allowedKeys = ["INSURANCE_CLAIM", "INSURANCE_CLAIM_PREAUTH"];
|
|
||||||
if (!allowedKeys.includes(groupTitleKey)) {
|
|
||||||
return sendError(
|
return sendError(
|
||||||
res,
|
res,
|
||||||
`Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}`
|
`Invalid groupTitleKey. Must be one of: ${allowedKeys.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GROUP_TITLES: Record<GroupKey, string> = {
|
||||||
|
INSURANCE_CLAIM: "Claims",
|
||||||
|
INSURANCE_CLAIM_PREAUTH: "Claims Preauth",
|
||||||
|
};
|
||||||
|
const groupTitle = GROUP_TITLES[groupTitleKey];
|
||||||
|
|
||||||
// ✅ Find or create PDF group for this claim
|
// ✅ Find or create PDF group for this claim
|
||||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||||
parsedPatientId,
|
parsedPatientId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import fs from "fs";
|
|||||||
import { prisma } from "@repo/db/client";
|
import { prisma } from "@repo/db/client";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import archiver from "archiver";
|
import archiver from "archiver";
|
||||||
|
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(userId);
|
||||||
|
|
||||||
// create a unique tmp directory for directory-format dump
|
// create a unique tmp directory for directory-format dump
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
||||||
|
|
||||||
@@ -132,9 +135,7 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
// attempt to respond with error if possible
|
// attempt to respond with error if possible
|
||||||
try {
|
try {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
|
||||||
.json({
|
|
||||||
error: "Failed to create archive",
|
error: "Failed to create archive",
|
||||||
details: err.message,
|
details: err.message,
|
||||||
});
|
});
|
||||||
@@ -187,9 +188,7 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
|||||||
// if headers not sent, send 500; otherwise destroy
|
// if headers not sent, send 500; otherwise destroy
|
||||||
try {
|
try {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res
|
res.status(500).json({
|
||||||
.status(500)
|
|
||||||
.json({
|
|
||||||
error: "Failed to finalize archive",
|
error: "Failed to finalize archive",
|
||||||
details: String(err),
|
details: String(err),
|
||||||
});
|
});
|
||||||
@@ -222,9 +221,9 @@ router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
|||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = await prisma.$queryRawUnsafe<{ size: string }[]>(
|
const size = await prisma.$queryRaw<{ size: string }[]>`
|
||||||
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||||
);
|
`;
|
||||||
|
|
||||||
const patientsCount = await storage.getTotalPatientCount();
|
const patientsCount = await storage.getTotalPatientCount();
|
||||||
const lastBackup = await storage.getLastBackup(userId);
|
const lastBackup = await storage.getLastBackup(userId);
|
||||||
@@ -244,4 +243,118 @@ router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination CRUD
|
||||||
|
// ==============================
|
||||||
|
|
||||||
|
// CREATE / UPDATE destination
|
||||||
|
router.post("/destination", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const { path: destinationPath } = req.body;
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
if (!destinationPath)
|
||||||
|
return res.status(400).json({ error: "Path is required" });
|
||||||
|
|
||||||
|
// validate path exists
|
||||||
|
if (!fs.existsSync(destinationPath)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Backup path does not exist or drive not connected",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination = await storage.createBackupDestination(
|
||||||
|
userId,
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
res.json(destination);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Failed to save backup destination" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET all destinations
|
||||||
|
router.get("/destination", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const destinations = await storage.getAllBackupDestination(userId);
|
||||||
|
res.json(destinations);
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE destination
|
||||||
|
router.put("/destination/:id", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { path: destinationPath } = req.body;
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
if (!destinationPath)
|
||||||
|
return res.status(400).json({ error: "Path is required" });
|
||||||
|
|
||||||
|
if (!fs.existsSync(destinationPath)) {
|
||||||
|
return res.status(400).json({ error: "Path does not exist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateBackupDestination(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
destinationPath
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE destination
|
||||||
|
router.delete("/destination/:id", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
await storage.deleteBackupDestination(id, userId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/backup-path", async (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const destination = await storage.getActiveBackupDestination(userId);
|
||||||
|
if (!destination) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "No backup destination configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(destination.path)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Backup destination not found. External drive may be disconnected.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `dental_backup_${Date.now()}.zip`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await backupDatabaseToPath({
|
||||||
|
destinationPath: destination.path,
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.createBackup(userId);
|
||||||
|
await storage.deleteNotificationsByType(userId, "BACKUP");
|
||||||
|
|
||||||
|
res.json({ success: true, filename });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Backup to destination failed",
|
||||||
|
details: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import patientDataExtractionRoutes from "./patientDataExtraction";
|
|||||||
import insuranceCredsRoutes from "./insuranceCreds";
|
import insuranceCredsRoutes from "./insuranceCreds";
|
||||||
import documentsRoutes from "./documents";
|
import documentsRoutes from "./documents";
|
||||||
import insuranceStatusRoutes from "./insuranceStatus";
|
import insuranceStatusRoutes from "./insuranceStatus";
|
||||||
|
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||||
import paymentsRoutes from "./payments";
|
import paymentsRoutes from "./payments";
|
||||||
import databaseManagementRoutes from "./database-management";
|
import databaseManagementRoutes from "./database-management";
|
||||||
import notificationsRoutes from "./notifications";
|
import notificationsRoutes from "./notifications";
|
||||||
@@ -27,6 +28,7 @@ router.use("/claims", claimsRoutes);
|
|||||||
router.use("/insuranceCreds", insuranceCredsRoutes);
|
router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||||
router.use("/documents", documentsRoutes);
|
router.use("/documents", documentsRoutes);
|
||||||
router.use("/insurance-status", insuranceStatusRoutes);
|
router.use("/insurance-status", insuranceStatusRoutes);
|
||||||
|
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||||
router.use("/payments", paymentsRoutes);
|
router.use("/payments", paymentsRoutes);
|
||||||
router.use("/database-management", databaseManagementRoutes);
|
router.use("/database-management", databaseManagementRoutes);
|
||||||
router.use("/notifications", notificationsRoutes);
|
router.use("/notifications", notificationsRoutes);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
|||||||
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
if (isNaN(id)) return res.status(400).send("Invalid ID");
|
||||||
|
|
||||||
// 1) Check existence
|
// 1) Check existence
|
||||||
const existing = await storage.getInsuranceCredential(userId);
|
const existing = await storage.getInsuranceCredential(id);
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return res.status(404).json({ message: "Credential not found" });
|
return res.status(404).json({ message: "Credential not found" });
|
||||||
|
|
||||||
|
|||||||
699
apps/Backend/src/routes/insuranceStatusDDMA.ts
Normal file
699
apps/Backend/src/routes/insuranceStatusDDMA.ts
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { storage } from "../storage";
|
||||||
|
import {
|
||||||
|
forwardToSeleniumDdmaEligibilityAgent,
|
||||||
|
forwardOtpToSeleniumDdmaAgent,
|
||||||
|
getSeleniumDdmaSessionStatus,
|
||||||
|
} from "../services/seleniumDdmaInsuranceEligibilityClient";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import fsSync from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import PDFDocument from "pdfkit";
|
||||||
|
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
|
||||||
|
import {
|
||||||
|
InsertPatient,
|
||||||
|
insertPatientSchema,
|
||||||
|
} from "../../../../packages/db/types/patient-types";
|
||||||
|
import { io } from "../socket";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/** Job context stored in memory by sessionId */
|
||||||
|
interface DdmaJobContext {
|
||||||
|
userId: number;
|
||||||
|
insuranceEligibilityData: any; // parsed, enriched (includes username/password)
|
||||||
|
socketId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ddmaJobs: Record<string, DdmaJobContext> = {};
|
||||||
|
|
||||||
|
/** Utility: naive name splitter */
|
||||||
|
function splitName(fullName?: string | null) {
|
||||||
|
if (!fullName) return { firstName: "", lastName: "" };
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const firstName = parts.shift() ?? "";
|
||||||
|
const lastName = parts.join(" ") ?? "";
|
||||||
|
return { firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const doc = new PDFDocument({ autoFirstPage: false });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
doc.on("data", (chunk: any) => chunks.push(chunk));
|
||||||
|
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on("error", (err: any) => reject(err));
|
||||||
|
|
||||||
|
const A4_WIDTH = 595.28; // points
|
||||||
|
const A4_HEIGHT = 841.89; // points
|
||||||
|
|
||||||
|
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
||||||
|
|
||||||
|
doc.image(imagePath, 0, 0, {
|
||||||
|
fit: [A4_WIDTH, A4_HEIGHT],
|
||||||
|
align: "center",
|
||||||
|
valign: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure patient exists for given insuranceId.
|
||||||
|
*/
|
||||||
|
async function createOrUpdatePatientByInsuranceId(options: {
|
||||||
|
insuranceId: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
dob?: string | Date | null;
|
||||||
|
userId: number;
|
||||||
|
}) {
|
||||||
|
const { insuranceId, firstName, lastName, dob, userId } = options;
|
||||||
|
if (!insuranceId) throw new Error("Missing insuranceId");
|
||||||
|
|
||||||
|
const incomingFirst = (firstName || "").trim();
|
||||||
|
const incomingLast = (lastName || "").trim();
|
||||||
|
|
||||||
|
let patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||||
|
|
||||||
|
if (patient && patient.id) {
|
||||||
|
const updates: any = {};
|
||||||
|
if (
|
||||||
|
incomingFirst &&
|
||||||
|
String(patient.firstName ?? "").trim() !== incomingFirst
|
||||||
|
) {
|
||||||
|
updates.firstName = incomingFirst;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
incomingLast &&
|
||||||
|
String(patient.lastName ?? "").trim() !== incomingLast
|
||||||
|
) {
|
||||||
|
updates.lastName = incomingLast;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await storage.updatePatient(patient.id, updates);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const createPayload: any = {
|
||||||
|
firstName: incomingFirst,
|
||||||
|
lastName: incomingLast,
|
||||||
|
dateOfBirth: dob,
|
||||||
|
gender: "",
|
||||||
|
phone: "",
|
||||||
|
userId,
|
||||||
|
insuranceId,
|
||||||
|
};
|
||||||
|
let patientData: InsertPatient;
|
||||||
|
try {
|
||||||
|
patientData = insertPatientSchema.parse(createPayload);
|
||||||
|
} catch (err) {
|
||||||
|
const safePayload = { ...createPayload };
|
||||||
|
delete (safePayload as any).dateOfBirth;
|
||||||
|
patientData = insertPatientSchema.parse(safePayload);
|
||||||
|
}
|
||||||
|
await storage.createPatient(patientData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When Selenium finishes for a given sessionId, run your patient + PDF pipeline,
|
||||||
|
* and return the final API response shape.
|
||||||
|
*/
|
||||||
|
async function handleDdmaCompletedJob(
|
||||||
|
sessionId: string,
|
||||||
|
job: DdmaJobContext,
|
||||||
|
seleniumResult: any
|
||||||
|
) {
|
||||||
|
let createdPdfFileId: number | null = null;
|
||||||
|
const outputResult: any = {};
|
||||||
|
|
||||||
|
// We'll wrap the processing in try/catch/finally so cleanup always runs
|
||||||
|
try {
|
||||||
|
// 1) ensuring memberid.
|
||||||
|
const insuranceEligibilityData = job.insuranceEligibilityData;
|
||||||
|
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||||
|
if (!insuranceId) {
|
||||||
|
throw new Error("Missing memberId for ddma job");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Create or update patient (with name from selenium result if available)
|
||||||
|
const patientNameFromResult =
|
||||||
|
typeof seleniumResult?.patientName === "string"
|
||||||
|
? seleniumResult.patientName.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { firstName, lastName } = splitName(patientNameFromResult);
|
||||||
|
|
||||||
|
await createOrUpdatePatientByInsuranceId({
|
||||||
|
insuranceId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
dob: insuranceEligibilityData.dateOfBirth,
|
||||||
|
userId: job.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Update patient status + PDF upload
|
||||||
|
const patient = await storage.getPatientByInsuranceId(
|
||||||
|
insuranceEligibilityData.memberId
|
||||||
|
);
|
||||||
|
if (!patient?.id) {
|
||||||
|
outputResult.patientUpdateStatus =
|
||||||
|
"Patient not found; no update performed";
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus: "none",
|
||||||
|
pdfFileId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// update patient status.
|
||||||
|
const newStatus =
|
||||||
|
seleniumResult.eligibility === "active" ? "ACTIVE" : "INACTIVE";
|
||||||
|
await storage.updatePatient(patient.id, { status: newStatus });
|
||||||
|
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||||
|
|
||||||
|
// convert screenshot -> pdf if available
|
||||||
|
let pdfBuffer: Buffer | null = null;
|
||||||
|
let generatedPdfPath: string | null = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
seleniumResult &&
|
||||||
|
seleniumResult.ss_path &&
|
||||||
|
typeof seleniumResult.ss_path === "string" &&
|
||||||
|
(seleniumResult.ss_path.endsWith(".png") ||
|
||||||
|
seleniumResult.ss_path.endsWith(".jpg") ||
|
||||||
|
seleniumResult.ss_path.endsWith(".jpeg"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!fsSync.existsSync(seleniumResult.ss_path)) {
|
||||||
|
throw new Error(
|
||||||
|
`Screenshot file not found: ${seleniumResult.ss_path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBuffer = await imageToPdfBuffer(seleniumResult.ss_path);
|
||||||
|
|
||||||
|
const pdfFileName = `ddma_eligibility_${insuranceEligibilityData.memberId}_${Date.now()}.pdf`;
|
||||||
|
generatedPdfPath = path.join(
|
||||||
|
path.dirname(seleniumResult.ss_path),
|
||||||
|
pdfFileName
|
||||||
|
);
|
||||||
|
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
||||||
|
|
||||||
|
// ensure cleanup uses this
|
||||||
|
seleniumResult.pdf_path = generatedPdfPath;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to convert screenshot to PDF:", err);
|
||||||
|
outputResult.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outputResult.pdfUploadStatus =
|
||||||
|
"No valid screenshot (ss_path) provided by Selenium; nothing to upload.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfBuffer && generatedPdfPath) {
|
||||||
|
const groupTitle = "Eligibility Status";
|
||||||
|
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||||
|
|
||||||
|
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||||
|
patient.id,
|
||||||
|
groupTitleKey
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
group = await storage.createPdfGroup(
|
||||||
|
patient.id,
|
||||||
|
groupTitle,
|
||||||
|
groupTitleKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!group?.id) {
|
||||||
|
throw new Error("PDF group creation failed: missing group ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await storage.createPdfFile(
|
||||||
|
group.id,
|
||||||
|
path.basename(generatedPdfPath),
|
||||||
|
pdfBuffer
|
||||||
|
);
|
||||||
|
if (created && typeof created === "object" && "id" in created) {
|
||||||
|
createdPdfFileId = Number(created.id);
|
||||||
|
}
|
||||||
|
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||||
|
} else {
|
||||||
|
outputResult.pdfUploadStatus =
|
||||||
|
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus: outputResult.pdfUploadStatus,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
patientUpdateStatus: outputResult.patientUpdateStatus,
|
||||||
|
pdfUploadStatus:
|
||||||
|
outputResult.pdfUploadStatus ??
|
||||||
|
`Failed to process DDMA job: ${err?.message ?? String(err)}`,
|
||||||
|
pdfFileId: createdPdfFileId,
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// ALWAYS attempt cleanup of temp files
|
||||||
|
try {
|
||||||
|
if (seleniumResult && seleniumResult.pdf_path) {
|
||||||
|
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
||||||
|
} else if (seleniumResult && seleniumResult.ss_path) {
|
||||||
|
await emptyFolderContainingFile(seleniumResult.ss_path);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[ddma-eligibility] no pdf_path or ss_path available to cleanup`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.error(
|
||||||
|
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path ?? seleniumResult?.ss_path}]`,
|
||||||
|
cleanupErr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- top of file, alongside ddmaJobs ---
|
||||||
|
let currentFinalSessionId: string | null = null;
|
||||||
|
let currentFinalResult: any = null;
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||||
|
if (!socketId) {
|
||||||
|
log("socket", "no socketId for emit", { event });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const socket = io?.sockets.sockets.get(socketId);
|
||||||
|
if (!socket) {
|
||||||
|
log("socket", "socket not found (maybe disconnected)", {
|
||||||
|
socketId,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.emit(event, payload);
|
||||||
|
log("socket", "emitted", { socketId, event });
|
||||||
|
} catch (err: any) {
|
||||||
|
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls Python agent for session status and emits socket events:
|
||||||
|
* - 'selenium:otp_required' when waiting_for_otp
|
||||||
|
* - 'selenium:session_update' when completed/error
|
||||||
|
* - rabsolute timeout + transient error handling.
|
||||||
|
* - pollTimeoutMs default = 2 minutes (adjust where invoked)
|
||||||
|
*/
|
||||||
|
async function pollAgentSessionAndProcess(
|
||||||
|
sessionId: string,
|
||||||
|
socketId?: string,
|
||||||
|
pollTimeoutMs = 2 * 60 * 1000
|
||||||
|
) {
|
||||||
|
const maxAttempts = 300;
|
||||||
|
const baseDelayMs = 1000;
|
||||||
|
const maxTransientErrors = 12;
|
||||||
|
|
||||||
|
// NEW: give up if same non-terminal status repeats this many times
|
||||||
|
const noProgressLimit = 100;
|
||||||
|
|
||||||
|
const job = ddmaJobs[sessionId];
|
||||||
|
let transientErrorCount = 0;
|
||||||
|
let consecutiveNoProgress = 0;
|
||||||
|
let lastStatus: string | null = null;
|
||||||
|
const deadline = Date.now() + pollTimeoutMs;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
// absolute deadline check
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: `Polling timeout reached (${Math.round(pollTimeoutMs / 1000)}s).`,
|
||||||
|
});
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
"poller",
|
||||||
|
`attempt=${attempt} session=${sessionId} transientErrCount=${transientErrorCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const st = await getSeleniumDdmaSessionStatus(sessionId);
|
||||||
|
const status = st?.status ?? null;
|
||||||
|
log("poller", "got status", {
|
||||||
|
sessionId,
|
||||||
|
status,
|
||||||
|
message: st?.message,
|
||||||
|
resultKeys: st?.result ? Object.keys(st.result) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset transient errors on success
|
||||||
|
transientErrorCount = 0;
|
||||||
|
|
||||||
|
// if status unchanged and non-terminal, increment no-progress counter
|
||||||
|
const isTerminalLike =
|
||||||
|
status === "completed" || status === "error" || status === "not_found";
|
||||||
|
if (status === lastStatus && !isTerminalLike) {
|
||||||
|
consecutiveNoProgress++;
|
||||||
|
} else {
|
||||||
|
consecutiveNoProgress = 0;
|
||||||
|
}
|
||||||
|
lastStatus = status;
|
||||||
|
|
||||||
|
// if no progress for too many consecutive polls -> abort
|
||||||
|
if (consecutiveNoProgress >= noProgressLimit) {
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: `No progress from selenium agent (status="${status}") after ${consecutiveNoProgress} polls; aborting.`,
|
||||||
|
});
|
||||||
|
emitSafe(socketId, "selenium:session_error", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: "No progress from selenium agent",
|
||||||
|
});
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always emit debug to client if socket exists
|
||||||
|
emitSafe(socketId, "selenium:debug", {
|
||||||
|
session_id: sessionId,
|
||||||
|
attempt,
|
||||||
|
status,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If agent is waiting for OTP, inform client but keep polling (do not return)
|
||||||
|
if (status === "waiting_for_otp") {
|
||||||
|
emitSafe(socketId, "selenium:otp_required", {
|
||||||
|
session_id: sessionId,
|
||||||
|
message: "OTP required. Please enter the OTP.",
|
||||||
|
});
|
||||||
|
// do not return — keep polling (allows same poller to pick up completion)
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed path
|
||||||
|
if (status === "completed") {
|
||||||
|
log("poller", "agent completed; processing result", {
|
||||||
|
sessionId,
|
||||||
|
resultKeys: st.result ? Object.keys(st.result) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist raw result so frontend can fetch if socket disconnects
|
||||||
|
currentFinalSessionId = sessionId;
|
||||||
|
currentFinalResult = {
|
||||||
|
rawSelenium: st.result,
|
||||||
|
processedAt: null,
|
||||||
|
final: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let finalResult: any = null;
|
||||||
|
if (job && st.result) {
|
||||||
|
try {
|
||||||
|
finalResult = await handleDdmaCompletedJob(
|
||||||
|
sessionId,
|
||||||
|
job,
|
||||||
|
st.result
|
||||||
|
);
|
||||||
|
currentFinalResult.final = finalResult;
|
||||||
|
currentFinalResult.processedAt = Date.now();
|
||||||
|
} catch (err: any) {
|
||||||
|
currentFinalResult.final = {
|
||||||
|
error: "processing_failed",
|
||||||
|
detail: err?.message ?? String(err),
|
||||||
|
};
|
||||||
|
currentFinalResult.processedAt = Date.now();
|
||||||
|
log("poller", "handleDdmaCompletedJob failed", {
|
||||||
|
sessionId,
|
||||||
|
err: err?.message ?? err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentFinalResult[sessionId].final = {
|
||||||
|
error: "no_job_or_no_result",
|
||||||
|
};
|
||||||
|
currentFinalResult[sessionId].processedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit final update (if socket present)
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "completed",
|
||||||
|
rawSelenium: st.result,
|
||||||
|
final: currentFinalResult.final,
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanup job context
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal error / not_found
|
||||||
|
if (status === "error" || status === "not_found") {
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status,
|
||||||
|
message: st?.message || "Selenium session error",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const axiosStatus =
|
||||||
|
err?.response?.status ?? (err?.status ? Number(err.status) : undefined);
|
||||||
|
const errCode = err?.code ?? err?.errno;
|
||||||
|
const errMsg = err?.message ?? String(err);
|
||||||
|
const errData = err?.response?.data ?? null;
|
||||||
|
|
||||||
|
// If agent explicitly returned 404 -> terminal (session gone)
|
||||||
|
if (
|
||||||
|
axiosStatus === 404 ||
|
||||||
|
(typeof errMsg === "string" && errMsg.includes("not_found"))
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] terminal 404/not_found for ${sessionId}: data=${JSON.stringify(errData)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit not_found to client
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "not_found",
|
||||||
|
message:
|
||||||
|
errData?.detail || "Selenium session not found (agent cleaned up).",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
|
||||||
|
// Remove job context and stop polling
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed transient error logging
|
||||||
|
transientErrorCount++;
|
||||||
|
if (transientErrorCount > maxTransientErrors) {
|
||||||
|
const emitPayload = {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message:
|
||||||
|
"Repeated network errors while polling selenium agent; giving up.",
|
||||||
|
};
|
||||||
|
emitSafe(socketId, "selenium:session_update", emitPayload);
|
||||||
|
emitSafe(socketId, "selenium:session_error", emitPayload);
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
30_000,
|
||||||
|
baseDelayMs * Math.pow(2, transientErrorCount - 1)
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] transient error (#${transientErrorCount}) for ${sessionId}: code=${errCode} status=${axiosStatus} msg=${errMsg} data=${JSON.stringify(errData)}`
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
`${new Date().toISOString()} [poller] backing off ${backoffMs}ms before next attempt`
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, backoffMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal poll interval
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// overall timeout fallback
|
||||||
|
emitSafe(socketId, "selenium:session_update", {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: "error",
|
||||||
|
message: "Polling timeout while waiting for selenium session",
|
||||||
|
});
|
||||||
|
delete ddmaJobs[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /ddma-eligibility
|
||||||
|
* Starts DDMA eligibility Selenium job.
|
||||||
|
* Expects:
|
||||||
|
* - req.body.data: stringified JSON like your existing /eligibility-check
|
||||||
|
* - req.body.socketId: socket.io client id
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/ddma-eligibility",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.body.data) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user || !req.user.id) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawData =
|
||||||
|
typeof req.body.data === "string"
|
||||||
|
? JSON.parse(req.body.data)
|
||||||
|
: req.body.data;
|
||||||
|
|
||||||
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
|
req.user.id,
|
||||||
|
rawData.insuranceSiteKey
|
||||||
|
);
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error:
|
||||||
|
"No insurance credentials found for this provider, Kindly Update this at Settings Page.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedData = {
|
||||||
|
...rawData,
|
||||||
|
massddmaUsername: credentials.username,
|
||||||
|
massddmaPassword: credentials.password,
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketId: string | undefined = req.body.socketId;
|
||||||
|
|
||||||
|
const agentResp =
|
||||||
|
await forwardToSeleniumDdmaEligibilityAgent(enrichedData);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!agentResp ||
|
||||||
|
agentResp.status !== "started" ||
|
||||||
|
!agentResp.session_id
|
||||||
|
) {
|
||||||
|
return res.status(502).json({
|
||||||
|
error: "Selenium agent did not return a started session",
|
||||||
|
detail: agentResp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = agentResp.session_id as string;
|
||||||
|
|
||||||
|
// Save job context
|
||||||
|
ddmaJobs[sessionId] = {
|
||||||
|
userId: req.user.id,
|
||||||
|
insuranceEligibilityData: enrichedData,
|
||||||
|
socketId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// start polling in background to notify client via socket and process job
|
||||||
|
pollAgentSessionAndProcess(sessionId, socketId).catch((e) =>
|
||||||
|
console.warn("pollAgentSessionAndProcess failed", e)
|
||||||
|
);
|
||||||
|
|
||||||
|
// reply immediately with started status
|
||||||
|
return res.json({ status: "started", session_id: sessionId });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: err.message || "Failed to start ddma selenium agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /selenium/submit-otp
|
||||||
|
* Body: { session_id, otp, socketId? }
|
||||||
|
* Forwards OTP to Python agent and optionally notifies client socket.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/selenium/submit-otp",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
const { session_id: sessionId, otp, socketId } = req.body;
|
||||||
|
if (!sessionId || !otp) {
|
||||||
|
return res.status(400).json({ error: "session_id and otp are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await forwardOtpToSeleniumDdmaAgent(sessionId, otp);
|
||||||
|
|
||||||
|
// emit OTP accepted (if socket present)
|
||||||
|
emitSafe(socketId, "selenium:otp_submitted", {
|
||||||
|
session_id: sessionId,
|
||||||
|
result: r,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(r);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"Failed to forward OTP:",
|
||||||
|
err?.response?.data || err?.message || err
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to forward otp to selenium agent",
|
||||||
|
detail: err?.message || err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /selenium/session/:sid/final
|
||||||
|
router.get(
|
||||||
|
"/selenium/session/:sid/final",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const sid = req.params.sid;
|
||||||
|
if (!sid) return res.status(400).json({ error: "session id required" });
|
||||||
|
|
||||||
|
// Only the current in-memory result is available
|
||||||
|
if (currentFinalSessionId !== sid || !currentFinalResult) {
|
||||||
|
return res.status(404).json({ error: "final result not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(currentFinalResult);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
85
apps/Backend/src/services/databaseBackupService.ts
Normal file
85
apps/Backend/src/services/databaseBackupService.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import archiver from "archiver";
|
||||||
|
|
||||||
|
function safeRmDir(dir: string) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupToPathParams {
|
||||||
|
destinationPath: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupDatabaseToPath({
|
||||||
|
destinationPath,
|
||||||
|
filename,
|
||||||
|
}: BackupToPathParams): Promise<void> {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_"));
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const pgDump = spawn(
|
||||||
|
"pg_dump",
|
||||||
|
[
|
||||||
|
"-Fd",
|
||||||
|
"-j",
|
||||||
|
"4",
|
||||||
|
"--no-acl",
|
||||||
|
"--no-owner",
|
||||||
|
"-h",
|
||||||
|
process.env.DB_HOST || "localhost",
|
||||||
|
"-U",
|
||||||
|
process.env.DB_USER || "postgres",
|
||||||
|
process.env.DB_NAME || "dental_db",
|
||||||
|
"-f",
|
||||||
|
tmpDir,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: process.env.DB_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let pgError = "";
|
||||||
|
|
||||||
|
pgDump.stderr.on("data", (d) => (pgError += d.toString()));
|
||||||
|
|
||||||
|
pgDump.on("close", async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
return reject(new Error(pgError || "pg_dump failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFile = path.join(destinationPath, filename);
|
||||||
|
const outputStream = fs.createWriteStream(outputFile);
|
||||||
|
|
||||||
|
const archive = archiver("zip");
|
||||||
|
|
||||||
|
outputStream.on("error", (err) => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on("error", (err) => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(outputStream);
|
||||||
|
archive.directory(tmpDir + path.sep, false);
|
||||||
|
|
||||||
|
archive.finalize();
|
||||||
|
|
||||||
|
archive.on("end", () => {
|
||||||
|
safeRmDir(tmpDir);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import http from "http";
|
||||||
|
import https from "https";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export interface SeleniumPayload {
|
||||||
|
data: any;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELENIUM_AGENT_BASE = process.env.SELENIUM_AGENT_BASE_URL;
|
||||||
|
|
||||||
|
const httpAgent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||||
|
const httpsAgent = new https.Agent({ keepAlive: true, keepAliveMsecs: 60_000 });
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: SELENIUM_AGENT_BASE,
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
httpAgent,
|
||||||
|
httpsAgent,
|
||||||
|
validateStatus: (s) => s >= 200 && s < 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requestWithRetries(
|
||||||
|
config: any,
|
||||||
|
retries = 4,
|
||||||
|
baseBackoffMs = 300
|
||||||
|
) {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await client.request(config);
|
||||||
|
if (![502, 503, 504].includes(r.status)) return r;
|
||||||
|
console.warn(
|
||||||
|
`[selenium-client] retryable HTTP status ${r.status} (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
const code = err?.code;
|
||||||
|
const isTransient =
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT";
|
||||||
|
if (!isTransient) throw err;
|
||||||
|
console.warn(
|
||||||
|
`[selenium-client] transient network error ${code} (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||||
|
}
|
||||||
|
// final attempt (let exception bubble if it fails)
|
||||||
|
return client.request(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
function log(tag: string, msg: string, ctx?: any) {
|
||||||
|
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardToSeleniumDdmaEligibilityAgent(
|
||||||
|
insuranceEligibilityData: any
|
||||||
|
): Promise<any> {
|
||||||
|
const payload = { data: insuranceEligibilityData };
|
||||||
|
const url = `/ddma-eligibility`;
|
||||||
|
log("selenium-client", "POST ddma-eligibility", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
keys: Object.keys(payload),
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||||
|
log("selenium-client", "agent response", {
|
||||||
|
status: r.status,
|
||||||
|
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||||
|
});
|
||||||
|
if (r.status >= 500)
|
||||||
|
throw new Error(`Selenium agent server error: ${r.status}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forwardOtpToSeleniumDdmaAgent(
|
||||||
|
sessionId: string,
|
||||||
|
otp: string
|
||||||
|
): Promise<any> {
|
||||||
|
const url = `/submit-otp`;
|
||||||
|
log("selenium-client", "POST submit-otp", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries(
|
||||||
|
{ url, method: "POST", data: { session_id: sessionId, otp } },
|
||||||
|
4
|
||||||
|
);
|
||||||
|
log("selenium-client", "submit-otp response", {
|
||||||
|
status: r.status,
|
||||||
|
data: r.data,
|
||||||
|
});
|
||||||
|
if (r.status >= 500)
|
||||||
|
throw new Error(`Selenium agent server error on submit-otp: ${r.status}`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeleniumDdmaSessionStatus(
|
||||||
|
sessionId: string
|
||||||
|
): Promise<any> {
|
||||||
|
const url = `/session/${sessionId}/status`;
|
||||||
|
log("selenium-client", "GET session status", {
|
||||||
|
url: SELENIUM_AGENT_BASE + url,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||||
|
log("selenium-client", "session status response", {
|
||||||
|
status: r.status,
|
||||||
|
dataKeys: r.data ? Object.keys(r.data) : null,
|
||||||
|
});
|
||||||
|
if (r.status === 404) {
|
||||||
|
const e: any = new Error("not_found");
|
||||||
|
e.response = { status: 404, data: r.data };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
53
apps/Backend/src/socket.ts
Normal file
53
apps/Backend/src/socket.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Server as HttpServer } from "http";
|
||||||
|
import { Server, Socket } from "socket.io";
|
||||||
|
|
||||||
|
let io: Server | null = null;
|
||||||
|
|
||||||
|
export function initSocket(server: HttpServer) {
|
||||||
|
const NODE_ENV = (
|
||||||
|
process.env.NODE_ENV ||
|
||||||
|
process.env.ENV ||
|
||||||
|
"development"
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
const rawFrontendUrls =
|
||||||
|
process.env.FRONTEND_URLS || process.env.FRONTEND_URL || "";
|
||||||
|
const FRONTEND_URLS = rawFrontendUrls
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// In dev: allow all origins
|
||||||
|
// In prod: restrict to FRONTEND_URLS if provided
|
||||||
|
const corsOrigin =
|
||||||
|
NODE_ENV !== "production"
|
||||||
|
? true
|
||||||
|
: FRONTEND_URLS.length > 0
|
||||||
|
? FRONTEND_URLS
|
||||||
|
: false; // no origins allowed if none configured in prod
|
||||||
|
|
||||||
|
io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: corsOrigin,
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket: Socket) => {
|
||||||
|
console.log("🔌 Socket connected:", socket.id);
|
||||||
|
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
console.log("🔌 Socket disconnected:", socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional: log low-level engine errors for debugging
|
||||||
|
io.engine.on("connection_error", (err) => {
|
||||||
|
console.error("Socket engine connection_error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return io;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { io };
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DatabaseBackup } from "@repo/db/types";
|
import { DatabaseBackup, BackupDestination } from "@repo/db/types";
|
||||||
import { prisma as db } from "@repo/db/client";
|
import { prisma as db } from "@repo/db/client";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
@@ -7,6 +7,33 @@ export interface IStorage {
|
|||||||
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
||||||
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
||||||
deleteBackups(userId: number): Promise<number>; // clears all for user
|
deleteBackups(userId: number): Promise<number>; // clears all for user
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination methods
|
||||||
|
// ==============================
|
||||||
|
createBackupDestination(
|
||||||
|
userId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<BackupDestination>;
|
||||||
|
|
||||||
|
getActiveBackupDestination(
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination | null>;
|
||||||
|
|
||||||
|
getAllBackupDestination(
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination[]>;
|
||||||
|
|
||||||
|
updateBackupDestination(
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
path: string
|
||||||
|
): Promise<BackupDestination>;
|
||||||
|
|
||||||
|
deleteBackupDestination(
|
||||||
|
id: number,
|
||||||
|
userId: number
|
||||||
|
): Promise<BackupDestination>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const databaseBackupStorage: IStorage = {
|
export const databaseBackupStorage: IStorage = {
|
||||||
@@ -36,4 +63,51 @@ export const databaseBackupStorage: IStorage = {
|
|||||||
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
||||||
return result.count;
|
return result.count;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Backup Destination methods
|
||||||
|
// ==============================
|
||||||
|
async createBackupDestination(userId, path) {
|
||||||
|
// deactivate existing destination
|
||||||
|
await db.backupDestination.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.backupDestination.create({
|
||||||
|
data: { userId, path },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveBackupDestination(userId) {
|
||||||
|
return db.backupDestination.findFirst({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllBackupDestination(userId) {
|
||||||
|
return db.backupDestination.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateBackupDestination(id, userId, path) {
|
||||||
|
// optional: make this one active
|
||||||
|
await db.backupDestination.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return db.backupDestination.update({
|
||||||
|
where: { id, userId },
|
||||||
|
data: { path, isActive: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteBackupDestination(id, userId) {
|
||||||
|
return db.backupDestination.delete({
|
||||||
|
where: { id, userId },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Dashboard from "./pages/dashboard";
|
|||||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||||
|
|
||||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||||
|
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||||
@@ -34,6 +35,7 @@ function Router() {
|
|||||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
||||||
|
|
||||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
||||||
|
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />} />
|
||||||
<ProtectedRoute
|
<ProtectedRoute
|
||||||
path="/appointments"
|
path="/appointments"
|
||||||
component={() => <AppointmentsPage />}
|
component={() => <AppointmentsPage />}
|
||||||
|
|||||||
@@ -964,6 +964,9 @@ export function ClaimForm({
|
|||||||
"childRecallDirect4BW",
|
"childRecallDirect4BW",
|
||||||
"childRecallDirect2PA2BW",
|
"childRecallDirect2PA2BW",
|
||||||
"childRecallDirect2PA4BW",
|
"childRecallDirect2PA4BW",
|
||||||
|
"childRecallDirect3PA2BW",
|
||||||
|
"childRecallDirect3PA",
|
||||||
|
"childRecallDirect4PA",
|
||||||
"childRecallDirectPANO",
|
"childRecallDirectPANO",
|
||||||
].map((comboId) => {
|
].map((comboId) => {
|
||||||
const b = PROCEDURE_COMBOS[comboId];
|
const b = PROCEDURE_COMBOS[comboId];
|
||||||
@@ -979,6 +982,9 @@ export function ClaimForm({
|
|||||||
childRecallDirect4BW: "Direct 4BW",
|
childRecallDirect4BW: "Direct 4BW",
|
||||||
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||||
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||||
|
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
||||||
|
childRecallDirect3PA: "Direct 3PA",
|
||||||
|
childRecallDirect4PA: "Direct 4PA",
|
||||||
childRecallDirectPANO: "Direct Pano",
|
childRecallDirectPANO: "Direct Pano",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -1015,6 +1021,7 @@ export function ClaimForm({
|
|||||||
"adultRecallDirect4BW",
|
"adultRecallDirect4BW",
|
||||||
"adultRecallDirect2PA2BW",
|
"adultRecallDirect2PA2BW",
|
||||||
"adultRecallDirect2PA4BW",
|
"adultRecallDirect2PA4BW",
|
||||||
|
"adultRecallDirect4PA",
|
||||||
"adultRecallDirectPano",
|
"adultRecallDirectPano",
|
||||||
].map((comboId) => {
|
].map((comboId) => {
|
||||||
const b = PROCEDURE_COMBOS[comboId];
|
const b = PROCEDURE_COMBOS[comboId];
|
||||||
@@ -1030,6 +1037,7 @@ export function ClaimForm({
|
|||||||
adultRecallDirect4BW: "Direct 4BW",
|
adultRecallDirect4BW: "Direct 4BW",
|
||||||
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||||
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||||
|
adultRecallDirect4PA: "Direct 4PA",
|
||||||
adultRecallDirectPano: "Direct Pano",
|
adultRecallDirectPano: "Direct Pano",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -1053,6 +1061,45 @@ export function ClaimForm({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ORTH GROUP */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium opacity-80">Orth</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
"orthPreExamDirect",
|
||||||
|
"orthRecordDirect",
|
||||||
|
"orthPerioVisitDirect",
|
||||||
|
"orthRetentionDirect",
|
||||||
|
].map((comboId) => {
|
||||||
|
const b = PROCEDURE_COMBOS[comboId];
|
||||||
|
if (!b) return null;
|
||||||
|
|
||||||
|
const tooltipText = b.codes.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={b.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => applyComboAndThenMH(b.id)}
|
||||||
|
aria-label={`${b.label} — codes: ${tooltipText}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent side="top" align="center">
|
||||||
|
<div className="text-sm max-w-xs break-words">
|
||||||
|
{tooltipText}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { FolderOpen, Trash2 } from "lucide-react";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export function BackupDestinationManager() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [path, setPath] = useState("");
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Queries
|
||||||
|
// ==============================
|
||||||
|
const { data: destinations = [] } = useQuery({
|
||||||
|
queryKey: ["/db/destination"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
"/api/database-management/destination"
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Mutations
|
||||||
|
// ==============================
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/database-management/destination",
|
||||||
|
{ path }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Backup destination saved" });
|
||||||
|
setPath("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await apiRequest("DELETE", `/api/database-management/destination/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Backup destination deleted" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/db/destination"] });
|
||||||
|
setDeleteId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Folder picker (browser limitation)
|
||||||
|
// ==============================
|
||||||
|
const openFolderPicker = async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (!window.showDirectoryPicker) {
|
||||||
|
toast({
|
||||||
|
title: "Not supported",
|
||||||
|
description: "Your browser does not support folder picking",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const dirHandle = await window.showDirectoryPicker();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Folder selected",
|
||||||
|
description: `Selected folder: ${dirHandle.name}. Please enter the full path manually.`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// user cancelled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// UI
|
||||||
|
// ==============================
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>External Backup Destination</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="/media/usb-drive or D:\\Backups"
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => setPath(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" onClick={openFolderPicker}>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={!path || saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save Destination
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{destinations.map((d: any) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className="flex justify-between items-center border rounded p-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-700">{d.path}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setDeleteId(d.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm delete dialog */}
|
||||||
|
<AlertDialog open={deleteId !== null}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete backup destination?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove the destination and stop automatic backups.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteId && deleteMutation.mutate(deleteId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { io as ioClient, Socket } from "socket.io-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { CheckCircle, LoaderCircleIcon, X } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { useAppDispatch } from "@/redux/hooks";
|
||||||
|
import { setTaskStatus } from "@/redux/slices/seleniumEligibilityCheckTaskSlice";
|
||||||
|
import { formatLocalDate } from "@/utils/dateUtils";
|
||||||
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
|
|
||||||
|
const SOCKET_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL_BACKEND ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : "");
|
||||||
|
|
||||||
|
// ---------- OTP Modal component ----------
|
||||||
|
interface DdmaOtpModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (otp: string) => Promise<void> | void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DdmaOtpModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: DdmaOtpModalProps) {
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setOtp("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!otp.trim()) return;
|
||||||
|
await onSubmit(otp.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-lg w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">Enter OTP</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-500 hover:text-slate-800"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
|
We need the one-time password (OTP) sent by the Delta Dental MA portal
|
||||||
|
to complete this eligibility check.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ddma-otp">OTP</Label>
|
||||||
|
<Input
|
||||||
|
id="ddma-otp"
|
||||||
|
placeholder="Enter OTP code"
|
||||||
|
value={otp}
|
||||||
|
onChange={(e) => setOtp(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !otp.trim()}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Submit OTP"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Main DDMA Eligibility button component ----------
|
||||||
|
interface DdmaEligibilityButtonProps {
|
||||||
|
memberId: string;
|
||||||
|
dateOfBirth: Date | null;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
isFormIncomplete: boolean;
|
||||||
|
/** Called when backend has finished and PDF is ready */
|
||||||
|
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DdmaEligibilityButton({
|
||||||
|
memberId,
|
||||||
|
dateOfBirth,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
isFormIncomplete,
|
||||||
|
onPdfReady,
|
||||||
|
}: DdmaEligibilityButtonProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const connectingRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||||
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||||
|
|
||||||
|
// Clean up socket on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.removeAllListeners();
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
connectingRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSocket = () => {
|
||||||
|
try {
|
||||||
|
socketRef.current?.removeAllListeners();
|
||||||
|
socketRef.current?.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lazy socket setup: called only when we actually need it (first click)
|
||||||
|
const ensureSocketConnected = async () => {
|
||||||
|
// If already connected, nothing to do
|
||||||
|
if (socketRef.current && socketRef.current.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a connection is in progress, reuse that promise
|
||||||
|
if (connectingRef.current) {
|
||||||
|
return connectingRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve, reject) => {
|
||||||
|
const socket = ioClient(SOCKET_URL, {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("DDMA socket connected:", socket.id);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// connection error when first connecting (or later)
|
||||||
|
socket.on("connect_error", (err: any) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Connection failed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Realtime connection failed",
|
||||||
|
description:
|
||||||
|
"Could not connect to realtime server. Retrying automatically...",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// do not reject here because socket.io will attempt reconnection
|
||||||
|
});
|
||||||
|
|
||||||
|
// socket.io will emit 'reconnect_attempt' for retries
|
||||||
|
socket.on("reconnect_attempt", (attempt: number) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: `Realtime reconnect attempt #${attempt}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// when reconnection failed after configured attempts
|
||||||
|
socket.on("reconnect_failed", () => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Reconnect failed",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Realtime reconnect failed",
|
||||||
|
description:
|
||||||
|
"Connection to realtime server could not be re-established. Please try again later.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// terminal failure — cleanup and reject so caller can stop start flow
|
||||||
|
closeSocket();
|
||||||
|
reject(new Error("Realtime reconnect failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("disconnect", (reason: any) => {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: "Connection disconnected",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "Connection Disconnected",
|
||||||
|
description:
|
||||||
|
"Connection to the server was lost. If a DDMA job was running it may have failed.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
// clear sessionId/OTP modal
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OTP required
|
||||||
|
socket.on("selenium:otp_required", (payload: any) => {
|
||||||
|
if (!payload?.session_id) return;
|
||||||
|
setSessionId(payload.session_id);
|
||||||
|
setOtpModalOpen(true);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "OTP required for DDMA eligibility. Please enter the OTP.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OTP submitted (optional UX)
|
||||||
|
socket.on("selenium:otp_submitted", (payload: any) => {
|
||||||
|
if (!payload?.session_id) return;
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "OTP submitted. Finishing DDMA eligibility check...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Session update
|
||||||
|
socket.on("selenium:session_update", (payload: any) => {
|
||||||
|
const { session_id, status, final } = payload || {};
|
||||||
|
if (!session_id) return;
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "success",
|
||||||
|
message:
|
||||||
|
"DDMA eligibility updated and PDF attached to patient documents.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DDMA eligibility complete",
|
||||||
|
description:
|
||||||
|
"Patient status was updated and the eligibility PDF was saved.",
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfId = final?.pdfFileId;
|
||||||
|
if (pdfId) {
|
||||||
|
const filename =
|
||||||
|
final?.pdfFilename ?? `eligibility_ddma_${memberId}.pdf`;
|
||||||
|
onPdfReady(Number(pdfId), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
} else if (status === "error") {
|
||||||
|
const msg =
|
||||||
|
payload?.message ||
|
||||||
|
final?.error ||
|
||||||
|
"DDMA eligibility session failed.";
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DDMA selenium error",
|
||||||
|
description: msg,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure socket is torn down for this session (stop receiving stale events)
|
||||||
|
try {
|
||||||
|
closeSocket();
|
||||||
|
} catch (e) {}
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||||
|
});
|
||||||
|
|
||||||
|
// explicit session error event (helpful)
|
||||||
|
socket.on("selenium:session_error", (payload: any) => {
|
||||||
|
const msg = payload?.message || "Selenium session error";
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: msg,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Selenium session error",
|
||||||
|
description: msg,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
// tear down socket to avoid stale updates
|
||||||
|
try {
|
||||||
|
closeSocket();
|
||||||
|
} catch (e) {}
|
||||||
|
setSessionId(null);
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If socket.io initial connection fails permanently (very rare: client-level)
|
||||||
|
// set a longer timeout to reject the first attempt to connect.
|
||||||
|
const initialConnectTimeout = setTimeout(() => {
|
||||||
|
if (!socket.connected) {
|
||||||
|
// if still not connected after 8s, treat as failure and reject so caller can handle it
|
||||||
|
closeSocket();
|
||||||
|
reject(new Error("Realtime initial connection timeout"));
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
// When the connect resolves we should clear this timer
|
||||||
|
socket.once("connect", () => {
|
||||||
|
clearTimeout(initialConnectTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// store promise to prevent multiple concurrent connections
|
||||||
|
connectingRef.current = promise;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} finally {
|
||||||
|
connectingRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDdmaEligibility = async () => {
|
||||||
|
if (!memberId || !dateOfBirth) {
|
||||||
|
toast({
|
||||||
|
title: "Missing fields",
|
||||||
|
description: "Member ID and Date of Birth are required.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDob = dateOfBirth ? formatLocalDate(dateOfBirth) : "";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
memberId,
|
||||||
|
dateOfBirth: formattedDob,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
insuranceSiteKey: "DDMA", // make sure this matches backend credential key
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsStarting(true);
|
||||||
|
|
||||||
|
// 1) Ensure socket is connected (lazy)
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "Opening realtime channel for DDMA eligibility...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await ensureSocketConnected();
|
||||||
|
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (!socket || !socket.connected) {
|
||||||
|
throw new Error("Socket connection failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketId = socket.id;
|
||||||
|
|
||||||
|
// 2) Start the selenium job via backend
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message: "Starting DDMA eligibility check via selenium...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/insurance-status-ddma/ddma-eligibility",
|
||||||
|
{
|
||||||
|
data: JSON.stringify(payload),
|
||||||
|
socketId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If apiRequest threw, we would have caught above; but just in case it returns.
|
||||||
|
let result: any = null;
|
||||||
|
let backendError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// attempt JSON first
|
||||||
|
result = await response.clone().json();
|
||||||
|
backendError =
|
||||||
|
result?.error || result?.message || result?.detail || null;
|
||||||
|
} catch {
|
||||||
|
// fallback to text response
|
||||||
|
try {
|
||||||
|
const text = await response.clone().text();
|
||||||
|
backendError = text?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
backendError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
backendError ||
|
||||||
|
`DDMA selenium start failed (status ${response.status})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal success path: optional: if backend returns non-error shape still check for result.error
|
||||||
|
if (result?.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "started" && result.session_id) {
|
||||||
|
setSessionId(result.session_id as string);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "pending",
|
||||||
|
message:
|
||||||
|
"DDMA eligibility job started. Waiting for OTP or final result...",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// fallback if backend returns immediate result
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "success",
|
||||||
|
message: "DDMA eligibility completed.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("startDdmaEligibility error:", err);
|
||||||
|
dispatch(
|
||||||
|
setTaskStatus({
|
||||||
|
status: "error",
|
||||||
|
message: err?.message || "Failed to start DDMA eligibility",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: "DDMA selenium error",
|
||||||
|
description: err?.message || "Failed to start DDMA eligibility",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitOtp = async (otp: string) => {
|
||||||
|
if (!sessionId || !socketRef.current || !socketRef.current.connected) {
|
||||||
|
toast({
|
||||||
|
title: "Session not ready",
|
||||||
|
description:
|
||||||
|
"Could not submit OTP because the DDMA session or socket is not ready.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmittingOtp(true);
|
||||||
|
const resp = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/insurance-status-ddma/selenium/submit-otp",
|
||||||
|
{
|
||||||
|
session_id: sessionId,
|
||||||
|
otp,
|
||||||
|
socketId: socketRef.current.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
throw new Error(data.error || "Failed to submit OTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// from here we rely on websocket events (otp_submitted + session_update)
|
||||||
|
setOtpModalOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("handleSubmitOtp error:", err);
|
||||||
|
toast({
|
||||||
|
title: "Failed to submit OTP",
|
||||||
|
description: err?.message || "Error forwarding OTP to selenium agent",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingOtp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="default"
|
||||||
|
disabled={isFormIncomplete || isStarting}
|
||||||
|
onClick={startDdmaEligibility}
|
||||||
|
>
|
||||||
|
{isStarting ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Delta MA Eligibility
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DdmaOtpModal
|
||||||
|
open={otpModalOpen}
|
||||||
|
onClose={() => setOtpModalOpen(false)}
|
||||||
|
onSubmit={handleSubmitOtp}
|
||||||
|
isSubmitting={isSubmittingOtp}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
Phone,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -27,6 +28,11 @@ export function Sidebar() {
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Patient Connection",
|
||||||
|
path: "/patient-connection",
|
||||||
|
icon: <Phone className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Appointments",
|
name: "Appointments",
|
||||||
path: "/appointments",
|
path: "/appointments",
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
import { Send, ArrowLeft } from "lucide-react";
|
||||||
|
import type { Patient, Communication } from "@repo/db/types";
|
||||||
|
import { format, isToday, isYesterday, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
interface MessageThreadProps {
|
||||||
|
patient: Patient;
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageThread({ patient, onBack }: MessageThreadProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [messageText, setMessageText] = useState("");
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: communications = [], isLoading } = useQuery<Communication[]>({
|
||||||
|
queryKey: ["/api/patients", patient.id, "communications"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/patients/${patient.id}/communications`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch communications");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds to get new messages
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessageMutation = useMutation({
|
||||||
|
mutationFn: async (message: string) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||||
|
to: patient.phone,
|
||||||
|
message: message,
|
||||||
|
patientId: patient.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setMessageText("");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["/api/patients", patient.id, "communications"],
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Message sent",
|
||||||
|
description: "Your message has been sent successfully.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to send message",
|
||||||
|
description:
|
||||||
|
error.message || "Unable to send message. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!messageText.trim()) return;
|
||||||
|
sendMessageMutation.mutate(messageText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [communications]);
|
||||||
|
|
||||||
|
const formatMessageDate = (dateValue: string | Date) => {
|
||||||
|
const date =
|
||||||
|
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
|
||||||
|
if (isToday(date)) {
|
||||||
|
return format(date, "h:mm a");
|
||||||
|
} else if (isYesterday(date)) {
|
||||||
|
return `Yesterday ${format(date, "h:mm a")}`;
|
||||||
|
} else {
|
||||||
|
return format(date, "MMM d, h:mm a");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateDivider = (dateValue: string | Date) => {
|
||||||
|
const messageDate =
|
||||||
|
typeof dateValue === "string" ? parseISO(dateValue) : dateValue;
|
||||||
|
if (isToday(messageDate)) {
|
||||||
|
return "Today";
|
||||||
|
} else if (isYesterday(messageDate)) {
|
||||||
|
return "Yesterday";
|
||||||
|
} else {
|
||||||
|
return format(messageDate, "MMMM d, yyyy");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedMessages: { date: string; messages: Communication[] }[] = [];
|
||||||
|
communications.forEach((comm) => {
|
||||||
|
if (!comm.createdAt) return;
|
||||||
|
const messageDate =
|
||||||
|
typeof comm.createdAt === "string"
|
||||||
|
? parseISO(comm.createdAt)
|
||||||
|
: comm.createdAt;
|
||||||
|
const dateKey = format(messageDate, "yyyy-MM-dd");
|
||||||
|
const existingGroup = groupedMessages.find((g) => g.date === dateKey);
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.messages.push(comm);
|
||||||
|
} else {
|
||||||
|
groupedMessages.push({ date: dateKey, messages: [comm] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white rounded-lg border">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBack}
|
||||||
|
data-testid="button-back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center font-semibold">
|
||||||
|
{patient.firstName[0]}
|
||||||
|
{patient.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-base">
|
||||||
|
{patient.firstName} {patient.lastName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{patient.phone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto p-6 space-y-4"
|
||||||
|
data-testid="messages-container"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-muted-foreground">Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
) : communications.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No messages yet. Start the conversation!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groupedMessages.map((group) => (
|
||||||
|
<div key={group.date}>
|
||||||
|
{/* Date Divider */}
|
||||||
|
<div className="flex items-center justify-center my-8">
|
||||||
|
<div className="px-4 py-1 bg-gray-100 rounded-full text-xs text-muted-foreground">
|
||||||
|
{getDateDivider(group.messages[0]?.createdAt!)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages for this date */}
|
||||||
|
{group.messages.map((comm) => (
|
||||||
|
<div
|
||||||
|
key={comm.id}
|
||||||
|
className={`flex mb-4 ${comm.direction === "outbound" ? "justify-end" : "justify-start"}`}
|
||||||
|
data-testid={`message-${comm.id}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-md ${comm.direction === "outbound" ? "ml-auto" : "mr-auto"}`}
|
||||||
|
>
|
||||||
|
{comm.direction === "inbound" && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-xs font-semibold flex-shrink-0">
|
||||||
|
{patient.firstName[0]}
|
||||||
|
{patient.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="p-3 rounded-2xl bg-gray-100 text-gray-900 rounded-tl-md">
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{comm.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{comm.createdAt &&
|
||||||
|
formatMessageDate(comm.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{comm.direction === "outbound" && (
|
||||||
|
<div>
|
||||||
|
<div className="p-3 rounded-2xl bg-primary text-primary-foreground rounded-tr-md">
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{comm.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 text-right">
|
||||||
|
{comm.createdAt &&
|
||||||
|
formatMessageDate(comm.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="p-4 border-t bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={messageText}
|
||||||
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="flex-1 rounded-full"
|
||||||
|
disabled={sendMessageMutation.isPending}
|
||||||
|
data-testid="input-message"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!messageText.trim() || sendMessageMutation.isPending}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full h-10 w-10"
|
||||||
|
data-testid="button-send"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { MessageSquare, Send, Loader2 } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { Patient } from "@repo/db/types";
|
||||||
|
|
||||||
|
interface SmsTemplateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
patient: Patient | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_TEMPLATES = {
|
||||||
|
appointment_reminder: {
|
||||||
|
name: "Appointment Reminder",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`,
|
||||||
|
},
|
||||||
|
appointment_confirmation: {
|
||||||
|
name: "Appointment Confirmation",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`,
|
||||||
|
},
|
||||||
|
follow_up: {
|
||||||
|
name: "Follow-Up",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`,
|
||||||
|
},
|
||||||
|
payment_reminder: {
|
||||||
|
name: "Payment Reminder",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`,
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
name: "General Message",
|
||||||
|
template: (firstName: string) =>
|
||||||
|
`Hi ${firstName}, this is your dental office. `,
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
name: "Custom Message",
|
||||||
|
template: () => "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SmsTemplateDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
patient,
|
||||||
|
}: SmsTemplateDialogProps) {
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<
|
||||||
|
keyof typeof MESSAGE_TEMPLATES
|
||||||
|
>("appointment_reminder");
|
||||||
|
const [customMessage, setCustomMessage] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const sendSmsMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
patientId,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
patientId: number;
|
||||||
|
}) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/send-sms", {
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
patientId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "SMS Sent Successfully",
|
||||||
|
description: `Message sent to ${patient?.firstName} ${patient?.lastName}`,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state
|
||||||
|
setSelectedTemplate("appointment_reminder");
|
||||||
|
setCustomMessage("");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Failed to Send SMS",
|
||||||
|
description:
|
||||||
|
error.message ||
|
||||||
|
"Please check your Twilio configuration and try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMessage = () => {
|
||||||
|
if (!patient) return "";
|
||||||
|
|
||||||
|
if (selectedTemplate === "custom") {
|
||||||
|
return customMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!patient || !patient.phone) return;
|
||||||
|
|
||||||
|
const message = getMessage();
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
sendSmsMutation.mutate({
|
||||||
|
to: patient.phone,
|
||||||
|
message: message,
|
||||||
|
patientId: Number(patient.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateChange = (value: string) => {
|
||||||
|
const templateKey = value as keyof typeof MESSAGE_TEMPLATES;
|
||||||
|
setSelectedTemplate(templateKey);
|
||||||
|
|
||||||
|
// Pre-fill custom message if not custom template
|
||||||
|
if (templateKey !== "custom" && patient) {
|
||||||
|
setCustomMessage(
|
||||||
|
MESSAGE_TEMPLATES[templateKey].template(patient.firstName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Send SMS to {patient?.firstName} {patient?.lastName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a message template or write a custom message
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="template">Message Template</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedTemplate}
|
||||||
|
onValueChange={handleTemplateChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="template" data-testid="select-sms-template">
|
||||||
|
<SelectValue placeholder="Select a template" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(MESSAGE_TEMPLATES).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="message">Message Preview</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={
|
||||||
|
selectedTemplate === "custom" ? customMessage : getMessage()
|
||||||
|
}
|
||||||
|
onChange={(e) => setCustomMessage(e.target.value)}
|
||||||
|
placeholder="Type your message here..."
|
||||||
|
rows={5}
|
||||||
|
className="resize-none"
|
||||||
|
data-testid="textarea-sms-message"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{patient?.phone
|
||||||
|
? `Will be sent to: ${patient.phone}`
|
||||||
|
: "No phone number available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={
|
||||||
|
!patient?.phone ||
|
||||||
|
!getMessage().trim() ||
|
||||||
|
sendSmsMutation.isPending
|
||||||
|
}
|
||||||
|
className="gap-2"
|
||||||
|
data-testid="button-send-sms"
|
||||||
|
>
|
||||||
|
{sendSmsMutation.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{sendSmsMutation.isPending ? "Sending..." : "Send SMS"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,13 +15,25 @@ async function throwIfResNotOk(res: Response) {
|
|||||||
|
|
||||||
// Try to parse the response as JSON for a more meaningful error message
|
// Try to parse the response as JSON for a more meaningful error message
|
||||||
let message = `${res.status}: ${res.statusText}`;
|
let message = `${res.status}: ${res.statusText}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errorBody = await res.json();
|
const errorBody = await res.clone().json();
|
||||||
if (errorBody?.message) {
|
|
||||||
|
if (errorBody?.error) {
|
||||||
|
message = errorBody.error;
|
||||||
|
} else if (errorBody?.message) {
|
||||||
message = errorBody.message;
|
message = errorBody.message;
|
||||||
|
} else if (errorBody?.detail) {
|
||||||
|
message = errorBody.detail;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore JSON parse errors, keep default message
|
// fallback to reading raw text so no error is lost
|
||||||
|
try {
|
||||||
|
const text = await res.clone().text();
|
||||||
|
if (text?.trim()) {
|
||||||
|
message = text.trim();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
|
|||||||
@@ -914,6 +914,14 @@ export default function AppointmentsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button disabled={true}>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
Claim Column A
|
||||||
|
</Button>
|
||||||
|
<Button disabled={true}>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
Claim Column B
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||||
|
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
|
||||||
|
|
||||||
export default function DatabaseManagementPage() {
|
export default function DatabaseManagementPage() {
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
@@ -205,6 +206,9 @@ export default function DatabaseManagementPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Externa Drive automatic backup manager */}
|
||||||
|
<BackupDestinationManager />
|
||||||
|
|
||||||
{/* Database Status Section */}
|
{/* Database Status Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { DateInput } from "@/components/ui/dateInput";
|
|||||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||||
|
|
||||||
export default function InsuranceStatusPage() {
|
export default function InsuranceStatusPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -43,7 +44,7 @@ export default function InsuranceStatusPage() {
|
|||||||
const [dateOfBirth, setDateOfBirth] = useState<Date | null>(null);
|
const [dateOfBirth, setDateOfBirth] = useState<Date | null>(null);
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
const isFormIncomplete = !memberId || !dateOfBirth || !firstName || !lastName;
|
const isFormIncomplete = !memberId || !dateOfBirth;
|
||||||
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isCheckingClaimStatus, setIsCheckingClaimStatus] = useState(false);
|
const [isCheckingClaimStatus, setIsCheckingClaimStatus] = useState(false);
|
||||||
@@ -574,19 +575,25 @@ export default function InsuranceStatusPage() {
|
|||||||
{/* TEMP PROVIDER BUTTONS */}
|
{/* TEMP PROVIDER BUTTONS */}
|
||||||
<div className="space-y-4 mt-6">
|
<div className="space-y-4 mt-6">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
Other provider checks (not working)
|
Other provider checks
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Row 1 */}
|
{/* Row 1 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Button
|
<DdmaEligibilityButton
|
||||||
className="w-full"
|
memberId={memberId}
|
||||||
variant="outline"
|
dateOfBirth={dateOfBirth}
|
||||||
disabled={isFormIncomplete}
|
firstName={firstName}
|
||||||
>
|
lastName={lastName}
|
||||||
<CheckCircle className="h-4 w-4 mr-2" />
|
isFormIncomplete={isFormIncomplete}
|
||||||
Delta MA
|
onPdfReady={(pdfId, fallbackFilename) => {
|
||||||
</Button>
|
setPreviewPdfId(pdfId);
|
||||||
|
setPreviewFallbackFilename(
|
||||||
|
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`
|
||||||
|
);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
380
apps/Frontend/src/pages/patient-connection-page.tsx
Normal file
380
apps/Frontend/src/pages/patient-connection-page.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { TopAppBar } from "@/components/layout/top-app-bar";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
PhoneCall,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
MessageSquare,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
||||||
|
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { Patient } from "@repo/db/types";
|
||||||
|
|
||||||
|
export default function PatientConnectionPage() {
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
|
const [isSmsDialogOpen, setIsSmsDialogOpen] = useState(false);
|
||||||
|
const [showMessaging, setShowMessaging] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeCallMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
to,
|
||||||
|
patientId,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
patientId: number;
|
||||||
|
}) => {
|
||||||
|
return apiRequest("POST", "/api/twilio/make-call", {
|
||||||
|
to,
|
||||||
|
message:
|
||||||
|
"Hello, this is a call from your dental office. We are calling to connect with you.",
|
||||||
|
patientId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Call Initiated",
|
||||||
|
description: "The call has been placed successfully.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Call Failed",
|
||||||
|
description:
|
||||||
|
error.message || "Unable to place the call. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all patients from database
|
||||||
|
const { data: patients = [], isLoading } = useQuery<Patient[]>({
|
||||||
|
queryKey: ["/api/patients"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter patients based on search term
|
||||||
|
const filteredPatients = patients.filter((patient) => {
|
||||||
|
if (!searchTerm) return false;
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
const fullName = `${patient.firstName} ${patient.lastName}`.toLowerCase();
|
||||||
|
const phone = patient.phone?.toLowerCase() || "";
|
||||||
|
const patientId = patient.id?.toString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
fullName.includes(searchLower) ||
|
||||||
|
phone.includes(searchLower) ||
|
||||||
|
patientId?.includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle calling patient via Twilio
|
||||||
|
const handleCall = (patient: Patient) => {
|
||||||
|
if (patient.phone) {
|
||||||
|
makeCallMutation.mutate({
|
||||||
|
to: patient.phone,
|
||||||
|
patientId: Number(patient.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle sending SMS
|
||||||
|
const handleSMS = (patient: Patient) => {
|
||||||
|
setSelectedPatient(patient);
|
||||||
|
setIsSmsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle opening messaging
|
||||||
|
const handleOpenMessaging = (patient: Patient) => {
|
||||||
|
setSelectedPatient(patient);
|
||||||
|
setShowMessaging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle closing messaging
|
||||||
|
const handleCloseMessaging = () => {
|
||||||
|
setShowMessaging(false);
|
||||||
|
setSelectedPatient(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sample call data
|
||||||
|
const recentCalls = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
patientName: "John Bill",
|
||||||
|
phoneNumber: "(555) 123-4567",
|
||||||
|
callType: "Appointment Request",
|
||||||
|
status: "Completed",
|
||||||
|
duration: "3:45",
|
||||||
|
time: "2 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
patientName: "Emily Brown",
|
||||||
|
phoneNumber: "(555) 987-6543",
|
||||||
|
callType: "Insurance Question",
|
||||||
|
status: "Follow-up Required",
|
||||||
|
duration: "6:12",
|
||||||
|
time: "4 hours ago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
patientName: "Mike Johnson",
|
||||||
|
phoneNumber: "(555) 456-7890",
|
||||||
|
callType: "Prescription Refill",
|
||||||
|
status: "Completed",
|
||||||
|
duration: "2:30",
|
||||||
|
time: "6 hours ago",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const callStats = [
|
||||||
|
{
|
||||||
|
label: "Total Calls Today",
|
||||||
|
value: "23",
|
||||||
|
icon: <Phone className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Answered Calls",
|
||||||
|
value: "21",
|
||||||
|
icon: <PhoneCall className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Average Call Time",
|
||||||
|
value: "4:32",
|
||||||
|
icon: <Clock className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active Patients",
|
||||||
|
value: patients.length.toString(),
|
||||||
|
icon: <User className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="container mx-auto space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
Patient Connection
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Search and communicate with patients
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Statistics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{callStats.map((stat, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{stat.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-primary">{stat.icon}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Actions */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Search Patients</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search by name, phone number, or patient ID
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search patient by name, phone, or ID..."
|
||||||
|
className="pl-8"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
data-testid="input-patient-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchTerm && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading patients...
|
||||||
|
</p>
|
||||||
|
) : filteredPatients.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
Search Results ({filteredPatients.length})
|
||||||
|
</p>
|
||||||
|
{filteredPatients.map((patient) => (
|
||||||
|
<div
|
||||||
|
key={patient.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
data-testid={`patient-result-${patient.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{patient.firstName} {patient.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{patient.phone || "No phone number"} • ID:{" "}
|
||||||
|
{patient.id}
|
||||||
|
</p>
|
||||||
|
{patient.email && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{patient.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCall(patient)}
|
||||||
|
disabled={!patient.phone}
|
||||||
|
data-testid={`button-call-${patient.id}`}
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4 mr-1" />
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenMessaging(patient)}
|
||||||
|
disabled={!patient.phone}
|
||||||
|
data-testid={`button-chat-${patient.id}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
|
Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No patients found matching "{searchTerm}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Calls */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Calls</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage recent patient calls
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentCalls.map((call) => (
|
||||||
|
<div
|
||||||
|
key={call.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Phone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{call.patientName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{call.phoneNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{call.callType}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Duration: {call.duration}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
call.status === "Completed" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{call.status}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground">{call.time}</p>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<PhoneCall className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* SMS Template Dialog */}
|
||||||
|
<SmsTemplateDialog
|
||||||
|
open={isSmsDialogOpen}
|
||||||
|
onOpenChange={setIsSmsDialogOpen}
|
||||||
|
patient={selectedPatient}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Messaging Interface */}
|
||||||
|
{showMessaging && selectedPatient && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-4xl h-[80vh] bg-white rounded-lg shadow-xl relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 right-4 z-10"
|
||||||
|
onClick={handleCloseMessaging}
|
||||||
|
data-testid="button-close-messaging"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="h-full">
|
||||||
|
<MessageThread
|
||||||
|
patient={selectedPatient}
|
||||||
|
onBack={handleCloseMessaging}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,30 @@ export const PROCEDURE_COMBOS: Record<
|
|||||||
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0274"],
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0274"],
|
||||||
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
|
toothNumbers: [null, null, null, "9", "24", null], // only these two need values
|
||||||
},
|
},
|
||||||
|
childRecallDirect3PA2BW: {
|
||||||
|
id: "childRecallDirect3PA2BW",
|
||||||
|
label: "Child Recall Direct 3PA 2BW",
|
||||||
|
codes: [
|
||||||
|
"D0120", // exam
|
||||||
|
"D1120", // prophy
|
||||||
|
"D1208", // fluoride
|
||||||
|
"D0220",
|
||||||
|
"D0230",
|
||||||
|
"D0230", // extra PA
|
||||||
|
"D0272", // 2BW
|
||||||
|
],
|
||||||
|
},
|
||||||
|
childRecallDirect4PA: {
|
||||||
|
id: "childRecallDirect4PA",
|
||||||
|
label: "Child Recall Direct 4PA",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230", "D0230"],
|
||||||
|
},
|
||||||
|
childRecallDirect3PA: {
|
||||||
|
id: "childRecallDirect3PA",
|
||||||
|
label: "Child Recall Direct 3PA",
|
||||||
|
codes: ["D0120", "D1120", "D1208", "D0220", "D0230", "D0230"],
|
||||||
|
},
|
||||||
|
|
||||||
childRecallDirectPANO: {
|
childRecallDirectPANO: {
|
||||||
id: "childRecallDirectPANO",
|
id: "childRecallDirectPANO",
|
||||||
label: "Child Recall Direct PANO",
|
label: "Child Recall Direct PANO",
|
||||||
@@ -77,6 +101,11 @@ export const PROCEDURE_COMBOS: Record<
|
|||||||
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
|
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
|
||||||
toothNumbers: [null, "9", "24", null, null], // only these two need values
|
toothNumbers: [null, "9", "24", null, null], // only these two need values
|
||||||
},
|
},
|
||||||
|
adultRecallDirect4PA: {
|
||||||
|
id: "adultRecallDirect4PA",
|
||||||
|
label: "Adult Recall Direct 4PA",
|
||||||
|
codes: ["D0120", "D1110", "D0220", "D0230", "D0230", "D0230"],
|
||||||
|
},
|
||||||
adultRecallDirectPano: {
|
adultRecallDirectPano: {
|
||||||
id: "adultRecallDirectPano",
|
id: "adultRecallDirectPano",
|
||||||
label: "Adult Recall Direct - PANO",
|
label: "Adult Recall Direct - PANO",
|
||||||
@@ -220,6 +249,33 @@ export const PROCEDURE_COMBOS: Record<
|
|||||||
label: "Baby Teeth EXT",
|
label: "Baby Teeth EXT",
|
||||||
codes: ["D7111"],
|
codes: ["D7111"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Orthodontics
|
||||||
|
orthPreExamDirect: {
|
||||||
|
id: "orthPreExamDirect",
|
||||||
|
label: "Pre-Orth Exam",
|
||||||
|
codes: ["D9310"],
|
||||||
|
},
|
||||||
|
orthRecordDirect: {
|
||||||
|
id: "orthRecordDirect",
|
||||||
|
label: "Orth Record",
|
||||||
|
codes: ["D8660"],
|
||||||
|
},
|
||||||
|
orthPerioVisitDirect: {
|
||||||
|
id: "orthPerioVisitDirect",
|
||||||
|
label: "Perio Orth Visit ",
|
||||||
|
codes: ["D8670"],
|
||||||
|
},
|
||||||
|
orthRetentionDirect: {
|
||||||
|
id: "orthRetentionDirect",
|
||||||
|
label: "Orth Retention",
|
||||||
|
codes: ["D8680"],
|
||||||
|
},
|
||||||
|
orthPA: {
|
||||||
|
id: "orthPA",
|
||||||
|
label: "Orth PA",
|
||||||
|
codes: ["D8080", "D8670", "D8660"],
|
||||||
|
},
|
||||||
// add more…
|
// add more…
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,4 +319,5 @@ export const COMBO_CATEGORIES: Record<
|
|||||||
"surgicalExtraction",
|
"surgicalExtraction",
|
||||||
"babyTeethExtraction",
|
"babyTeethExtraction",
|
||||||
],
|
],
|
||||||
|
Orthodontics: ["orthPA"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,6 +7,8 @@ from selenium_eligibilityCheckWorker import AutomationMassHealthEligibilityCheck
|
|||||||
from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck
|
from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck
|
||||||
from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import helpers_ddma_eligibility as hddma
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -137,6 +139,79 @@ async def start_workflow(request: Request):
|
|||||||
async with lock:
|
async with lock:
|
||||||
active_jobs -= 1
|
active_jobs -= 1
|
||||||
|
|
||||||
|
# Endpoint:5 - DDMA eligibility (background, OTP)
|
||||||
|
|
||||||
|
async def _ddma_worker_wrapper(sid: str, data: dict, url: str):
|
||||||
|
"""
|
||||||
|
Background worker that:
|
||||||
|
- acquires semaphore (to keep 1 selenium at a time),
|
||||||
|
- updates active/queued counters,
|
||||||
|
- runs the DDMA flow via helpers.start_ddma_run.
|
||||||
|
"""
|
||||||
|
global active_jobs, waiting_jobs
|
||||||
|
async with semaphore:
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs -= 1
|
||||||
|
active_jobs += 1
|
||||||
|
try:
|
||||||
|
await hddma.start_ddma_run(sid, data, url)
|
||||||
|
finally:
|
||||||
|
async with lock:
|
||||||
|
active_jobs -= 1
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ddma-eligibility")
|
||||||
|
async def ddma_eligibility(request: Request):
|
||||||
|
"""
|
||||||
|
Starts a DDMA eligibility session in the background.
|
||||||
|
Body: { "data": { ... }, "url"?: string }
|
||||||
|
Returns: { status: "started", session_id: "<uuid>" }
|
||||||
|
"""
|
||||||
|
global waiting_jobs
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
data = body.get("data", {})
|
||||||
|
|
||||||
|
# create session
|
||||||
|
sid = hddma.make_session_entry()
|
||||||
|
hddma.sessions[sid]["type"] = "ddma_eligibility"
|
||||||
|
hddma.sessions[sid]["last_activity"] = time.time()
|
||||||
|
|
||||||
|
async with lock:
|
||||||
|
waiting_jobs += 1
|
||||||
|
|
||||||
|
# run in background (queued under semaphore)
|
||||||
|
asyncio.create_task(_ddma_worker_wrapper(sid, data, url="https://providers.deltadentalma.com/onboarding/start/"))
|
||||||
|
|
||||||
|
return {"status": "started", "session_id": sid}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/submit-otp")
|
||||||
|
async def submit_otp(request: Request):
|
||||||
|
"""
|
||||||
|
Body: { "session_id": "<sid>", "otp": "123456" }
|
||||||
|
Node / frontend call this when user provides OTP.
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
sid = body.get("session_id")
|
||||||
|
otp = body.get("otp")
|
||||||
|
if not sid or not otp:
|
||||||
|
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||||
|
|
||||||
|
res = hddma.submit_otp(sid, otp)
|
||||||
|
if res.get("status") == "error":
|
||||||
|
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/session/{sid}/status")
|
||||||
|
async def session_status(sid: str):
|
||||||
|
s = hddma.get_session_status(sid)
|
||||||
|
if s.get("status") == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail="session not found")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
# ✅ Status Endpoint
|
# ✅ Status Endpoint
|
||||||
@app.get("/status")
|
@app.get("/status")
|
||||||
async def get_status():
|
async def get_status():
|
||||||
|
|||||||
248
apps/SeleniumService/helpers_ddma_eligibility.py
Normal file
248
apps/SeleniumService/helpers_ddma_eligibility.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Any
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.common.exceptions import WebDriverException
|
||||||
|
|
||||||
|
from selenium_DDMA_eligibilityCheckWorker import AutomationDeltaDentalMAEligibilityCheck
|
||||||
|
|
||||||
|
# In-memory session store
|
||||||
|
sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
SESSION_OTP_TIMEOUT = int(os.getenv("SESSION_OTP_TIMEOUT", "120")) # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_entry() -> str:
|
||||||
|
"""Create a new session entry and return its ID."""
|
||||||
|
import uuid
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
sessions[sid] = {
|
||||||
|
"status": "created", # created -> running -> waiting_for_otp -> otp_submitted -> completed / error
|
||||||
|
"created_at": time.time(),
|
||||||
|
"last_activity": time.time(),
|
||||||
|
"bot": None, # worker instance
|
||||||
|
"driver": None, # selenium webdriver
|
||||||
|
"otp_event": asyncio.Event(),
|
||||||
|
"otp_value": None,
|
||||||
|
"result": None,
|
||||||
|
"message": None,
|
||||||
|
"type": None,
|
||||||
|
}
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_session(sid: str, message: str | None = None):
|
||||||
|
"""
|
||||||
|
Close driver (if any), wake OTP waiter, set final state, and remove session entry.
|
||||||
|
Idempotent: safe to call multiple times.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Ensure final state
|
||||||
|
try:
|
||||||
|
if s.get("status") not in ("completed", "error", "not_found"):
|
||||||
|
s["status"] = "error"
|
||||||
|
if message:
|
||||||
|
s["message"] = message
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wake any OTP waiter (so awaiting coroutines don't hang)
|
||||||
|
try:
|
||||||
|
ev = s.get("otp_event")
|
||||||
|
if ev and not ev.is_set():
|
||||||
|
ev.set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Attempt to quit driver (may already be dead)
|
||||||
|
driver = s.get("driver")
|
||||||
|
if driver:
|
||||||
|
try:
|
||||||
|
driver.quit()
|
||||||
|
except Exception:
|
||||||
|
# ignore errors from quit (session already gone)
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Remove session entry from map
|
||||||
|
sessions.pop(sid, None)
|
||||||
|
print(f"[helpers] cleaned session {sid}")
|
||||||
|
|
||||||
|
async def _remove_session_later(sid: str, delay: int = 20):
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await cleanup_session(sid)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_ddma_run(sid: str, data: dict, url: str):
|
||||||
|
"""
|
||||||
|
Run the DDMA workflow for a session (WITHOUT managing semaphore/counters).
|
||||||
|
Called by agent.py inside a wrapper that handles queue/counters.
|
||||||
|
"""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
|
||||||
|
s["status"] = "running"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bot = AutomationDeltaDentalMAEligibilityCheck({"data": data})
|
||||||
|
bot.config_driver()
|
||||||
|
|
||||||
|
s["bot"] = bot
|
||||||
|
s["driver"] = bot.driver
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
# Navigate to login URL
|
||||||
|
try:
|
||||||
|
if not url:
|
||||||
|
raise ValueError("URL not provided for DDMA run")
|
||||||
|
bot.driver.maximize_window()
|
||||||
|
bot.driver.get(url)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Navigation failed: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# Login
|
||||||
|
try:
|
||||||
|
login_result = bot.login(url)
|
||||||
|
except WebDriverException as wde:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Selenium driver error during login: {wde}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Unexpected error during login: {e}"
|
||||||
|
await cleanup_session(sid, s["message"])
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
# OTP required path
|
||||||
|
if isinstance(login_result, str) and login_result == "OTP_REQUIRED":
|
||||||
|
s["status"] = "waiting_for_otp"
|
||||||
|
s["message"] = "OTP required for login"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(s["otp_event"].wait(), timeout=SESSION_OTP_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = "OTP timeout"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": "OTP not provided in time"}
|
||||||
|
|
||||||
|
otp_value = s.get("otp_value")
|
||||||
|
if not otp_value:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = "OTP missing after event"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": "OTP missing after event"}
|
||||||
|
|
||||||
|
# Submit OTP in the same Selenium window
|
||||||
|
try:
|
||||||
|
driver = s["driver"]
|
||||||
|
wait = WebDriverWait(driver, 30)
|
||||||
|
|
||||||
|
otp_input = wait.until(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otp_input.clear()
|
||||||
|
otp_input.send_keys(otp_value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
submit_btn = wait.until(
|
||||||
|
EC.element_to_be_clickable(
|
||||||
|
(By.XPATH, "//button[@type='button' and @aria-label='Verify']")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
submit_btn.click()
|
||||||
|
except Exception:
|
||||||
|
otp_input.send_keys("\n")
|
||||||
|
|
||||||
|
s["status"] = "otp_submitted"
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"Failed to submit OTP into page: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
elif isinstance(login_result, str) and login_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = login_result
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
|
||||||
|
# Step 1
|
||||||
|
step1_result = bot.step1()
|
||||||
|
if isinstance(step1_result, str) and step1_result.startswith("ERROR"):
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = step1_result
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
# Step 2 (PDF)
|
||||||
|
step2_result = bot.step2()
|
||||||
|
if isinstance(step2_result, dict) and step2_result.get("status") == "success":
|
||||||
|
s["status"] = "completed"
|
||||||
|
s["result"] = step2_result
|
||||||
|
s["message"] = "completed"
|
||||||
|
asyncio.create_task(_remove_session_later(sid, 30))
|
||||||
|
return step2_result
|
||||||
|
else:
|
||||||
|
s["status"] = "error"
|
||||||
|
if isinstance(step2_result, dict):
|
||||||
|
s["message"] = step2_result.get("message", "unknown error")
|
||||||
|
else:
|
||||||
|
s["message"] = str(step2_result)
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
s["status"] = "error"
|
||||||
|
s["message"] = f"worker exception: {e}"
|
||||||
|
await cleanup_session(sid)
|
||||||
|
return {"status": "error", "message": s["message"]}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_otp(sid: str, otp: str) -> Dict[str, Any]:
|
||||||
|
"""Set OTP for a session and wake waiting runner."""
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "error", "message": "session not found"}
|
||||||
|
if s.get("status") != "waiting_for_otp":
|
||||||
|
return {"status": "error", "message": f"session not waiting for otp (state={s.get('status')})"}
|
||||||
|
s["otp_value"] = otp
|
||||||
|
s["last_activity"] = time.time()
|
||||||
|
try:
|
||||||
|
s["otp_event"].set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "ok", "message": "otp accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_status(sid: str) -> Dict[str, Any]:
|
||||||
|
s = sessions.get(sid)
|
||||||
|
if not s:
|
||||||
|
return {"status": "not_found"}
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"status": s.get("status"),
|
||||||
|
"message": s.get("message"),
|
||||||
|
"created_at": s.get("created_at"),
|
||||||
|
"last_activity": s.get("last_activity"),
|
||||||
|
"result": s.get("result") if s.get("status") == "completed" else None,
|
||||||
|
}
|
||||||
298
apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py
Normal file
298
apps/SeleniumService/selenium_DDMA_eligibilityCheckWorker.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
from selenium import webdriver
|
||||||
|
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.keys import Keys
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from webdriver_manager.chrome import ChromeDriverManager
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
class AutomationDeltaDentalMAEligibilityCheck:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.headless = False
|
||||||
|
self.driver = None
|
||||||
|
|
||||||
|
self.data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
# Flatten values for convenience
|
||||||
|
self.memberId = self.data.get("memberId", "")
|
||||||
|
self.dateOfBirth = self.data.get("dateOfBirth", "")
|
||||||
|
self.massddma_username = self.data.get("massddmaUsername", "")
|
||||||
|
self.massddma_password = self.data.get("massddmaPassword", "")
|
||||||
|
|
||||||
|
self.download_dir = os.path.abspath("seleniumDownloads")
|
||||||
|
os.makedirs(self.download_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def config_driver(self):
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
if 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):
|
||||||
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
try:
|
||||||
|
self.driver.get(url)
|
||||||
|
email_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='username' and @type='text']")))
|
||||||
|
email_field.clear()
|
||||||
|
email_field.send_keys(self.massddma_username)
|
||||||
|
|
||||||
|
password_field = wait.until(EC.presence_of_element_located((By.XPATH, "//input[@name='password' and @type='password']")))
|
||||||
|
password_field.clear()
|
||||||
|
password_field.send_keys(self.massddma_password)
|
||||||
|
|
||||||
|
# remember me
|
||||||
|
try:
|
||||||
|
remember_me_checkbox = wait.until(EC.element_to_be_clickable(
|
||||||
|
(By.XPATH, "//label[.//span[contains(text(),'Remember me')]]")
|
||||||
|
))
|
||||||
|
remember_me_checkbox.click()
|
||||||
|
except:
|
||||||
|
print("[login] Remember me checkbox not found (continuing).")
|
||||||
|
|
||||||
|
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='submit' and @aria-label='Sign in']")))
|
||||||
|
login_button.click()
|
||||||
|
|
||||||
|
# OTP detection
|
||||||
|
try:
|
||||||
|
otp_candidate = WebDriverWait(self.driver, 30).until(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.XPATH, "//input[contains(@aria-lable,'Verification code') or contains(@placeholder,'Enter your verification code')]")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if otp_candidate:
|
||||||
|
print("[login] OTP input detected -> OTP_REQUIRED")
|
||||||
|
return "OTP_REQUIRED"
|
||||||
|
except TimeoutException:
|
||||||
|
print("[login] No OTP input detected in allowed time.")
|
||||||
|
except Exception as e:
|
||||||
|
print("[login] Exception during login:", e)
|
||||||
|
return f"ERROR:LOGIN FAILED: {e}"
|
||||||
|
|
||||||
|
def step1(self):
|
||||||
|
wait = WebDriverWait(self.driver, 30)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fill Member ID
|
||||||
|
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//input[@placeholder="Search by member ID"]')))
|
||||||
|
member_id_input.clear()
|
||||||
|
member_id_input.send_keys(self.memberId)
|
||||||
|
|
||||||
|
# Fill DOB parts
|
||||||
|
try:
|
||||||
|
dob_parts = self.dateOfBirth.split("-")
|
||||||
|
year = dob_parts[0] # "1964"
|
||||||
|
month = dob_parts[1].zfill(2) # "04"
|
||||||
|
day = dob_parts[2].zfill(2) # "17"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing DOB: {e}")
|
||||||
|
return "ERROR: PARSING DOB"
|
||||||
|
|
||||||
|
# 1) locate the specific member DOB container
|
||||||
|
dob_container = wait.until(
|
||||||
|
EC.presence_of_element_located(
|
||||||
|
(By.XPATH, "//div[@data-testid='member-search_date-of-birth']")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) find the editable spans *inside that container* using relative XPaths
|
||||||
|
month_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='month' and @contenteditable='true']")
|
||||||
|
day_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='day' and @contenteditable='true']")
|
||||||
|
year_elem = dob_container.find_element(By.XPATH, ".//span[@data-type='year' and @contenteditable='true']")
|
||||||
|
|
||||||
|
# Helper to click, select-all and type (pure send_keys approach)
|
||||||
|
def replace_with_sendkeys(el, value):
|
||||||
|
# focus (same as click)
|
||||||
|
el.click()
|
||||||
|
# select all (Ctrl+A) and delete (some apps pick up BACKSPACE better — we use BACKSPACE after select)
|
||||||
|
el.send_keys(Keys.CONTROL, "a")
|
||||||
|
el.send_keys(Keys.BACKSPACE)
|
||||||
|
# type the value
|
||||||
|
el.send_keys(value)
|
||||||
|
# optionally blur or tab out if app expects it
|
||||||
|
# el.send_keys(Keys.TAB)
|
||||||
|
|
||||||
|
replace_with_sendkeys(month_elem, month)
|
||||||
|
time.sleep(0.05)
|
||||||
|
replace_with_sendkeys(day_elem, day)
|
||||||
|
time.sleep(0.05)
|
||||||
|
replace_with_sendkeys(year_elem, year)
|
||||||
|
|
||||||
|
|
||||||
|
# Click Continue button
|
||||||
|
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//button[@data-testid="member-search_search-button"]')))
|
||||||
|
continue_btn.click()
|
||||||
|
|
||||||
|
# Check for error message
|
||||||
|
try:
|
||||||
|
error_msg = WebDriverWait(self.driver, 5).until(EC.presence_of_element_located(
|
||||||
|
(By.XPATH, '//div[@data-testid="member-search-result-no-results"]')
|
||||||
|
))
|
||||||
|
if error_msg:
|
||||||
|
print("Error: Invalid Member ID or Date of Birth.")
|
||||||
|
return "ERROR: INVALID MEMBERID OR DOB"
|
||||||
|
except TimeoutException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "Success"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error while step1 i.e Cheking the MemberId and DOB in: {e}")
|
||||||
|
return "ERROR:STEP1"
|
||||||
|
|
||||||
|
|
||||||
|
def step2(self):
|
||||||
|
wait = WebDriverWait(self.driver, 90)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1) find the eligibility <a> inside the correct cell
|
||||||
|
status_link = wait.until(EC.presence_of_element_located((
|
||||||
|
By.XPATH,
|
||||||
|
"(//tbody//tr)[1]//a[contains(@href, 'member-eligibility-search')]"
|
||||||
|
)))
|
||||||
|
|
||||||
|
eligibilityText = status_link.text.strip().lower()
|
||||||
|
|
||||||
|
# 2) finding patient name.
|
||||||
|
patient_name_div = wait.until(EC.presence_of_element_located((
|
||||||
|
By.XPATH,
|
||||||
|
'//div[@class="flex flex-row w-full items-center"]'
|
||||||
|
)))
|
||||||
|
|
||||||
|
patientName = patient_name_div.text.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
WebDriverWait(self.driver, 30).until(
|
||||||
|
lambda d: d.execute_script("return document.readyState") == "complete"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print("Warning: document.readyState did not become 'complete' within timeout")
|
||||||
|
|
||||||
|
# Give some time for lazy content to finish rendering (adjust if needed)
|
||||||
|
time.sleep(0.6)
|
||||||
|
|
||||||
|
# Get total page size and DPR
|
||||||
|
total_width = int(self.driver.execute_script(
|
||||||
|
"return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.documentElement.clientWidth);"
|
||||||
|
))
|
||||||
|
total_height = int(self.driver.execute_script(
|
||||||
|
"return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.documentElement.clientHeight);"
|
||||||
|
))
|
||||||
|
dpr = float(self.driver.execute_script("return window.devicePixelRatio || 1;"))
|
||||||
|
|
||||||
|
# Set device metrics to the full page size so Page.captureScreenshot captures everything
|
||||||
|
# Note: Some pages are extremely tall; if you hit memory limits, you can capture in chunks.
|
||||||
|
self.driver.execute_cdp_cmd('Emulation.setDeviceMetricsOverride', {
|
||||||
|
"mobile": False,
|
||||||
|
"width": total_width,
|
||||||
|
"height": total_height,
|
||||||
|
"deviceScaleFactor": dpr,
|
||||||
|
"screenOrientation": {"angle": 0, "type": "portraitPrimary"}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Small pause for layout to settle after emulation change
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
# Capture screenshot (base64 PNG)
|
||||||
|
result = self.driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "fromSurface": True})
|
||||||
|
image_data = base64.b64decode(result.get('data', ''))
|
||||||
|
screenshot_path = os.path.join(self.download_dir, f"ss_{self.memberId}.png")
|
||||||
|
with open(screenshot_path, "wb") as f:
|
||||||
|
f.write(image_data)
|
||||||
|
|
||||||
|
# Restore original metrics to avoid affecting further interactions
|
||||||
|
try:
|
||||||
|
self.driver.execute_cdp_cmd('Emulation.clearDeviceMetricsOverride', {})
|
||||||
|
except Exception:
|
||||||
|
# non-fatal: continue
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("Screenshot saved at:", screenshot_path)
|
||||||
|
output = {
|
||||||
|
"status": "success",
|
||||||
|
"eligibility": eligibilityText,
|
||||||
|
"ss_path": screenshot_path,
|
||||||
|
"patientName":patientName
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
except Exception as e:
|
||||||
|
print("ERROR in step2:", e)
|
||||||
|
# Empty the download folder (remove files / symlinks only)
|
||||||
|
try:
|
||||||
|
dl = os.path.abspath(self.download_dir)
|
||||||
|
if os.path.isdir(dl):
|
||||||
|
for name in os.listdir(dl):
|
||||||
|
item = os.path.join(dl, name)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(item) or os.path.islink(item):
|
||||||
|
os.remove(item)
|
||||||
|
print(f"[cleanup] removed: {item}")
|
||||||
|
except Exception as rm_err:
|
||||||
|
print(f"[cleanup] failed to remove {item}: {rm_err}")
|
||||||
|
print(f"[cleanup] emptied download dir: {dl}")
|
||||||
|
else:
|
||||||
|
print(f"[cleanup] download dir does not exist: {dl}")
|
||||||
|
except Exception as cleanup_exc:
|
||||||
|
print(f"[cleanup] unexpected error while cleaning downloads dir: {cleanup_exc}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 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):
|
||||||
|
try:
|
||||||
|
self.config_driver()
|
||||||
|
self.driver.maximize_window()
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
login_result = self.login(url)
|
||||||
|
if login_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": login_result}
|
||||||
|
if login_result == "OTP_REQUIRED":
|
||||||
|
return {"status": "otp_required", "message": "OTP required after login"}
|
||||||
|
|
||||||
|
step1_result = self.step1()
|
||||||
|
if step1_result.startswith("ERROR"):
|
||||||
|
return {"status": "error", "message": step1_result}
|
||||||
|
|
||||||
|
step2_result = self.step2()
|
||||||
|
if step2_result.get("status") == "error":
|
||||||
|
return {"status": "error", "message": step2_result.get("message")}
|
||||||
|
|
||||||
|
return step2_result
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": e
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if self.driver:
|
||||||
|
self.driver.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
43
docs/server-setup.md
Normal file
43
docs/server-setup.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Server Setup Guide
|
||||||
|
|
||||||
|
## 1. Update System Packages
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Install Node.js (LTS)
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Install Python 3
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
|
python3 --version
|
||||||
|
pip3 --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Pip global config :
|
||||||
|
```bash
|
||||||
|
sudo bash -c 'echo -e "[global]\nbreak-system-packages = true" > /etc/pip.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
-- this is optional, either create a venv for separate python based app in repo, or simply have global permissson by this.
|
||||||
|
|
||||||
|
## 4. Install PostgreSQL
|
||||||
|
```bash
|
||||||
|
sudo apt install -y postgresql postgresql-contrib
|
||||||
|
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Install Git
|
||||||
|
```bash
|
||||||
|
sudo apt install -y git
|
||||||
|
```
|
||||||
1511
package-lock.json
generated
1511
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,9 +8,9 @@
|
|||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"check-types": "turbo run check-types",
|
"check-types": "turbo run check-types",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"db:generate": "prisma generate --schema=packages/db/prisma/schema.prisma && ts-node packages/db/scripts/patch-zod-buffer.ts",
|
"db:generate": "npx prisma generate --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma && npx ts-node packages/db/scripts/patch-prisma-imports.ts && ts-node packages/db/scripts/patch-zod-buffer.ts",
|
||||||
"db:migrate": "dotenv -e packages/db/.env -- prisma migrate dev --schema=packages/db/prisma/schema.prisma",
|
"db:migrate": "npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||||
"db:seed": "prisma db seed --schema=packages/db/prisma/schema.prisma",
|
"db:seed": "npx prisma db seed --config=packages/db/prisma/prisma.config.ts --schema=packages/db/prisma/schema.prisma",
|
||||||
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
|
"setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env",
|
||||||
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"prisma": "^6.16.2",
|
|
||||||
"turbo": "^2.5.3"
|
"turbo": "^2.5.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -33,11 +32,12 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^7.0.1",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
|
"prisma": "^7.0.1",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"shx": "^0.4.0"
|
"shx": "^0.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
197
packages/db/docs/migration-case-fresh-baseline.md
Normal file
197
packages/db/docs/migration-case-fresh-baseline.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
|
||||||
|
These are the approach, required in special case when:
|
||||||
|
we migrating db from backup file to one pc1 to pc2. where pc1 was in dev mode.
|
||||||
|
|
||||||
|
so to just get the data from backup db, and start fresh migrations with prisma in pc2. we follow below approach.
|
||||||
|
else, we need to copy the pc1 migrations folder as well. instead of below approach.
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Starting point (what we already have)
|
||||||
|
|
||||||
|
* `backup.tar.gz` on PC2
|
||||||
|
* `prisma/schema.prisma` (from GitHub) in your repo
|
||||||
|
* `.env` with `DATABASE_URL` (or `packages/db/.env` used by your scripts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1 — Extract and restore the DB backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create extraction dir and extract
|
||||||
|
mkdir -p /tmp/dental_dump_dir
|
||||||
|
tar -xzf /path/to/backup.tar.gz -C /tmp/dental_dump_dir
|
||||||
|
|
||||||
|
# create DB (if not exists) and restore (data-only or custom format)
|
||||||
|
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
|
||||||
|
# (or use /usr/lib/postgresql/<ver>/bin/pg_restore if version mismatch)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2 — Confirm DB has tables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "\dt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3 — (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';"
|
||||||
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5 — (If present) remove old Prisma bookkeeping from DB
|
||||||
|
|
||||||
|
> We prefer to *not* use the old history from PC1 and create a fresh baseline on PC2.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# truncate migration records (bookkeeping only)
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6 — Create a migrations directory + baseline migration folder (bookkeeping)
|
||||||
|
|
||||||
|
From project root (where `prisma/schema.prisma` lives — in your repo it’s `packages/db/prisma/schema.prisma`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create migrations dir if missing (adjust path if your prisma folder is elsewhere)
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7 — Generate the full baseline SQL (so Prisma’s expected schema matches DB)
|
||||||
|
|
||||||
|
Use Prisma `migrate diff` to produce SQL that creates your current schema, writing it into the migration file you created:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# replace the folder name with the real one printed above, e.g. 20251203101323_init
|
||||||
|
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).
|
||||||
|
|
||||||
|
**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
|
||||||
|
Database schema is up to date!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10 — Run your project migration command (global npm script)
|
||||||
|
|
||||||
|
Now run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
# or, if your script uses flags, it will use the same schema/env
|
||||||
|
```
|
||||||
|
|
||||||
|
It should no longer print drift or ask to reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11 — Extra / troubleshooting commands we used (keep these handy)
|
||||||
|
|
||||||
|
* Inspect migration rows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='mypassword' psql -U postgres -h localhost -d dentalapp -c "SELECT id,migration_name,finished_at FROM _prisma_migrations ORDER BY finished_at;"
|
||||||
|
```
|
||||||
|
|
||||||
|
* Recreate DB from saved backup (if needed):
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='mypassword' dropdb -U postgres -h localhost dentalapp
|
||||||
|
PGPASSWORD='mypassword' createdb -U postgres -h localhost dentalapp
|
||||||
|
PGPASSWORD='mypassword' pg_restore -U postgres -h localhost -d dentalapp /path/to/backup.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
* Show top of a file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,60p' packages/db/prisma/migrations/20251203101323_init/migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ DATABASE_URL=postgresql://postgres:mypassword@localhost:5432/dentalapp
|
|||||||
|
|
||||||
# 🧠 Step 7 — Tips
|
# 🧠 Step 7 — Tips
|
||||||
|
|
||||||
- Use the same PostgreSQL version as the main PC.
|
- IMP: Use the same PostgreSQL version as the main PC. - currently more than v17.
|
||||||
|
|
||||||
- For large databases, use parallel restore for speed:
|
- For large databases, use parallel restore for speed:
|
||||||
|
|
||||||
@@ -150,3 +150,11 @@ sudo apt install -y postgresql-client-17
|
|||||||
```
|
```
|
||||||
PGPASSWORD='mypassword' /usr/lib/postgresql/17/bin/pg_restore -v -U postgres -h localhost -C -d postgres ./backup.dump
|
PGPASSWORD='mypassword' /usr/lib/postgresql/17/bin/pg_restore -v -U postgres -h localhost -C -d postgres ./backup.dump
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# If error comes while creating normal db with password:
|
||||||
|
|
||||||
|
- then, give the postgres user its password.
|
||||||
|
```
|
||||||
|
sudo -u postgres psql -c "ALTER ROLE postgres WITH PASSWORD 'mypassword';"
|
||||||
|
```
|
||||||
|
|||||||
@@ -12,18 +12,18 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./client": "./src/index.ts",
|
"./client": "./src/index.ts",
|
||||||
"./shared/schemas" : "./shared/schemas/index.ts",
|
"./shared/schemas": "./shared/schemas/index.ts",
|
||||||
"./usedSchemas" : "./usedSchemas/index.ts",
|
"./usedSchemas": "./usedSchemas/index.ts",
|
||||||
"./generated/prisma" : "./generated/prisma/index.d.ts",
|
"./types": "./types/index.ts"
|
||||||
"./types" : "./types/index.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.10.0",
|
"@prisma/adapter-pg": "^7.0.1",
|
||||||
"prisma-zod-generator": "^0.8.13"
|
"@prisma/client": "^7.0.0",
|
||||||
|
"prisma-zod-generator": "^2.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.17",
|
"@types/node": "^22.15.17",
|
||||||
"prisma": "^6.7.0",
|
"prisma": "^7.0.0",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
12
packages/db/prisma/prisma.config.ts
Normal file
12
packages/db/prisma/prisma.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -16,7 +16,6 @@ generator zod {
|
|||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -30,9 +29,11 @@ model User {
|
|||||||
insuranceCredentials InsuranceCredential[]
|
insuranceCredentials InsuranceCredential[]
|
||||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||||
backups DatabaseBackup[]
|
backups DatabaseBackup[]
|
||||||
|
backupDestinations BackupDestination[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
cloudFolders CloudFolder[]
|
cloudFolders CloudFolder[]
|
||||||
cloudFiles CloudFile[]
|
cloudFiles CloudFile[]
|
||||||
|
communications Communication[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Patient {
|
model Patient {
|
||||||
@@ -60,6 +61,7 @@ model Patient {
|
|||||||
claims Claim[]
|
claims Claim[]
|
||||||
groups PdfGroup[]
|
groups PdfGroup[]
|
||||||
payment Payment[]
|
payment Payment[]
|
||||||
|
communications Communication[]
|
||||||
|
|
||||||
@@index([insuranceId])
|
@@index([insuranceId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -300,6 +302,7 @@ enum PaymentMethod {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database management page
|
||||||
model DatabaseBackup {
|
model DatabaseBackup {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
@@ -311,6 +314,16 @@ model DatabaseBackup {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BackupDestination {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
path String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
@@ -379,3 +392,46 @@ model CloudFileChunk {
|
|||||||
@@unique([fileId, seq])
|
@@unique([fileId, seq])
|
||||||
@@index([fileId, seq])
|
@@index([fileId, seq])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// patient-connection-
|
||||||
|
enum CommunicationChannel {
|
||||||
|
sms
|
||||||
|
voice
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommunicationDirection {
|
||||||
|
outbound
|
||||||
|
inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommunicationStatus {
|
||||||
|
queued
|
||||||
|
sent
|
||||||
|
delivered
|
||||||
|
failed
|
||||||
|
completed
|
||||||
|
busy
|
||||||
|
no_answer
|
||||||
|
}
|
||||||
|
|
||||||
|
model Communication {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
patientId Int
|
||||||
|
userId Int?
|
||||||
|
|
||||||
|
channel CommunicationChannel
|
||||||
|
direction CommunicationDirection
|
||||||
|
status CommunicationStatus
|
||||||
|
|
||||||
|
body String?
|
||||||
|
callDuration Int?
|
||||||
|
twilioSid String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
patient Patient @relation(fields: [patientId], references: [id])
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@map("communications")
|
||||||
|
}
|
||||||
|
|||||||
274
packages/db/scripts/patch-prisma-imports.ts
Normal file
274
packages/db/scripts/patch-prisma-imports.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
/**
|
||||||
|
* patch-prisma-imports.ts (SAFE)
|
||||||
|
*
|
||||||
|
* - Converts value-level imports/exports of `Prisma` -> type-only imports/exports
|
||||||
|
* (splits mixed imports).
|
||||||
|
* - Replaces runtime usages of `Prisma.Decimal` -> `Decimal`.
|
||||||
|
* - Ensures exactly one `import Decimal from "decimal.js";` per file.
|
||||||
|
* - DEDICATED: only modifies TypeScript source files (.ts/.tsx).
|
||||||
|
* - SKIPS: files under packages/db/generated/prisma (the Prisma runtime package).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx ts-node packages/db/scripts/patch-prisma-imports.ts
|
||||||
|
*
|
||||||
|
* Run after `prisma generate` (and make sure generated runtime .js are restored
|
||||||
|
* if they were modified — see notes below).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import fg from "fast-glob";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const GENERATED_FRAGMENT = path.join("packages", "db", "generated", "prisma");
|
||||||
|
|
||||||
|
// Only operate on TS sources (do NOT touch .js)
|
||||||
|
const GLOBS = [
|
||||||
|
"packages/db/shared/**/*.ts",
|
||||||
|
"packages/db/shared/**/*.tsx",
|
||||||
|
"packages/db/generated/**/*.ts",
|
||||||
|
"packages/db/generated/**/*.tsx",
|
||||||
|
];
|
||||||
|
|
||||||
|
// -------------------- helpers --------------------
|
||||||
|
|
||||||
|
function isFromGeneratedPrisma(fromPath: string) {
|
||||||
|
// match relative imports that include generated/prisma
|
||||||
|
return (
|
||||||
|
fromPath.includes("generated/prisma") ||
|
||||||
|
fromPath.includes("/generated/prisma") ||
|
||||||
|
fromPath.includes("\\generated\\prisma")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSpecifiers(list: string) {
|
||||||
|
return list
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNamedImport(specs: string[]) {
|
||||||
|
return `{ ${specs.join(", ")} }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDecimalLines(src: string) {
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
const matches: number[] = [];
|
||||||
|
|
||||||
|
const regexes = [
|
||||||
|
/^import\s+Decimal\s+from\s+['"]decimal\.js['"]\s*;?/,
|
||||||
|
/^import\s+\{\s*Decimal\s*\}\s+from\s+['"]decimal\.js['"]\s*;?/,
|
||||||
|
/^import\s+\*\s+as\s+Decimal\s+from\s+['"]decimal\.js['"]\s*;?/,
|
||||||
|
/^(const|let|var)\s+Decimal\s*=\s*require\(\s*['"]decimal\.js['"]\s*\)\s*;?/,
|
||||||
|
/^(const|let|var)\s+Decimal\s*=\s*require\(\s*['"]decimal\.js['"]\s*\)\.default\s*;?/,
|
||||||
|
];
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
for (const re of regexes) {
|
||||||
|
if (re.test(line)) {
|
||||||
|
matches.push(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { lines, matches };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSingleDecimalImport(src: string) {
|
||||||
|
const { lines, matches } = extractDecimalLines(src);
|
||||||
|
|
||||||
|
if (matches.length === 0) return src;
|
||||||
|
|
||||||
|
// remove all matched import/require lines
|
||||||
|
// do in reverse index order to keep indices valid
|
||||||
|
matches
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((idx) => lines.splice(idx, 1));
|
||||||
|
|
||||||
|
let result = lines.join("\n");
|
||||||
|
|
||||||
|
// insert single canonical import if missing
|
||||||
|
if (!/import\s+Decimal\s+from\s+['"]decimal\.js['"]/.test(result)) {
|
||||||
|
const importBlockMatch = result.match(/^(?:\s*import[\s\S]*?;\r?\n)+/);
|
||||||
|
if (importBlockMatch && importBlockMatch.index !== undefined) {
|
||||||
|
const idx = importBlockMatch[0].length;
|
||||||
|
result =
|
||||||
|
result.slice(0, idx) +
|
||||||
|
`\nimport Decimal from "decimal.js";\n` +
|
||||||
|
result.slice(idx);
|
||||||
|
} else {
|
||||||
|
result = `import Decimal from "decimal.js";\n` + result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapse excessive blank lines
|
||||||
|
result = result.replace(/\n{3,}/g, "\n\n");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePrismaDecimalRuntime(src: string) {
|
||||||
|
if (!/\bPrisma\.Decimal\b/.test(src)) return { out: src, changed: false };
|
||||||
|
|
||||||
|
// mask import/export-from lines so we don't accidentally change them
|
||||||
|
const placeholder =
|
||||||
|
"__MASK_IMPORT_EXPORT__" + Math.random().toString(36).slice(2);
|
||||||
|
const saved: string[] = [];
|
||||||
|
|
||||||
|
const masked = src.replace(
|
||||||
|
/(^\s*(?:import|export)\s+[\s\S]*?from\s+['"][^'"]+['"]\s*;?)/gm,
|
||||||
|
(m) => {
|
||||||
|
saved.push(m);
|
||||||
|
return `${placeholder}${saved.length - 1}__\n`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const replaced = masked.replace(/\bPrisma\.Decimal\b/g, "Decimal");
|
||||||
|
|
||||||
|
const restored = replaced.replace(
|
||||||
|
new RegExp(`${placeholder}(\\d+)__\\n`, "g"),
|
||||||
|
(_m, i) => saved[Number(i)] || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
return { out: restored, changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- patching logic --------------------
|
||||||
|
|
||||||
|
function patchFileContent(src: string, filePath: string) {
|
||||||
|
// safety: do not edit runtime prisma package files
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
if (normalized.includes(path.normalize(GENERATED_FRAGMENT))) {
|
||||||
|
// skip any files inside packages/db/generated/prisma
|
||||||
|
return { out: src, changed: false, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = src;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
// 1) Named imports
|
||||||
|
out = out.replace(
|
||||||
|
/import\s+(?!type)(\{[^}]+\})\s+from\s+(['"])([^'"]+)\2\s*;?/gm,
|
||||||
|
(match, specBlock: string, q: string, fromPath: string) => {
|
||||||
|
if (!isFromGeneratedPrisma(fromPath)) return match;
|
||||||
|
|
||||||
|
const specList = specBlock.replace(/^\{|\}$/g, "").trim();
|
||||||
|
const specs = splitSpecifiers(specList);
|
||||||
|
|
||||||
|
const prismaEntries = specs.filter((s) =>
|
||||||
|
/^\s*Prisma(\s+as\s+\w+)?\s*$/.test(s)
|
||||||
|
);
|
||||||
|
const otherEntries = specs.filter(
|
||||||
|
(s) => !/^\s*Prisma(\s+as\s+\w+)?\s*$/.test(s)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prismaEntries.length === 0) return match;
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
let replacement = `import type ${buildNamedImport(prismaEntries)} from ${q}${fromPath}${q};`;
|
||||||
|
if (otherEntries.length > 0) {
|
||||||
|
replacement += `\nimport ${buildNamedImport(otherEntries)} from ${q}${fromPath}${q};`;
|
||||||
|
}
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) Named exports
|
||||||
|
out = out.replace(
|
||||||
|
/export\s+(?!type)(\{[^}]+\})\s+from\s+(['"])([^'"]+)\2\s*;?/gm,
|
||||||
|
(match, specBlock: string, q: string, fromPath: string) => {
|
||||||
|
if (!isFromGeneratedPrisma(fromPath)) return match;
|
||||||
|
|
||||||
|
const specList = specBlock.replace(/^\{|\}$/g, "").trim();
|
||||||
|
const specs = splitSpecifiers(specList);
|
||||||
|
|
||||||
|
const prismaEntries = specs.filter((s) =>
|
||||||
|
/^\s*Prisma(\s+as\s+\w+)?\s*$/.test(s)
|
||||||
|
);
|
||||||
|
const otherEntries = specs.filter(
|
||||||
|
(s) => !/^\s*Prisma(\s+as\s+\w+)?\s*$/.test(s)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prismaEntries.length === 0) return match;
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
let replacement = `export type ${buildNamedImport(prismaEntries)} from ${q}${fromPath}${q};`;
|
||||||
|
if (otherEntries.length > 0) {
|
||||||
|
replacement += `\nexport ${buildNamedImport(otherEntries)} from ${q}${fromPath}${q};`;
|
||||||
|
}
|
||||||
|
return replacement;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3) Namespace imports
|
||||||
|
out = out.replace(
|
||||||
|
/import\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+(['"])([^'"]+)\2\s*;?/gm,
|
||||||
|
(match, ns: string, q: string, fromPath: string) => {
|
||||||
|
if (!isFromGeneratedPrisma(fromPath)) return match;
|
||||||
|
changed = true;
|
||||||
|
return `import type * as ${ns} from ${q}${fromPath}${q};`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4) Default imports
|
||||||
|
out = out.replace(
|
||||||
|
/import\s+(?!type)([A-Za-z0-9_$]+)\s+from\s+(['"])([^'"]+)\2\s*;?/gm,
|
||||||
|
(match, binding: string, q: string, fromPath: string) => {
|
||||||
|
if (!isFromGeneratedPrisma(fromPath)) return match;
|
||||||
|
changed = true;
|
||||||
|
return `import type ${binding} from ${q}${fromPath}${q};`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5) Replace Prisma.Decimal -> Decimal safely
|
||||||
|
if (/\bPrisma\.Decimal\b/.test(out)) {
|
||||||
|
const { out: decimalOut, changed: decimalChanged } =
|
||||||
|
replacePrismaDecimalRuntime(out);
|
||||||
|
out = decimalOut;
|
||||||
|
if (decimalChanged) changed = true;
|
||||||
|
// Ensure a single Decimal import exists
|
||||||
|
out = ensureSingleDecimalImport(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { out, changed, skipped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- runner --------------------
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const files = await fg(GLOBS, { absolute: true, cwd: repoRoot, dot: true });
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
"No files matched. Check the GLOBS patterns and run from repo root."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const src = fs.readFileSync(file, "utf8");
|
||||||
|
const { out, changed, skipped } = patchFileContent(src, file);
|
||||||
|
if (skipped) {
|
||||||
|
// intentionally skipped runtime-prisma files
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (changed && out !== src) {
|
||||||
|
fs.writeFileSync(file, out, "utf8");
|
||||||
|
console.log("patched:", path.relative(repoRoot, file));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("failed patching", file, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,8 +1,152 @@
|
|||||||
import { PrismaClient } from "../generated/prisma";
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
type AnyFn = new (...a: any[]) => any;
|
||||||
|
let PrismaClientCtor: AnyFn | undefined;
|
||||||
|
|
||||||
|
// --- load generated or installed PrismaClient ctor ---
|
||||||
|
try {
|
||||||
|
// prefer the local generated client in the monorepo
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||||
|
const local = require("../generated/prisma");
|
||||||
|
PrismaClientCtor =
|
||||||
|
local.PrismaClient ||
|
||||||
|
local.default ||
|
||||||
|
(typeof local === "function" ? local : undefined);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||||
|
const installed = require("@prisma/client");
|
||||||
|
PrismaClientCtor =
|
||||||
|
installed.PrismaClient ||
|
||||||
|
installed.default ||
|
||||||
|
(typeof installed === "function" ? installed : undefined);
|
||||||
|
} catch (e2) {
|
||||||
|
throw new Error(
|
||||||
|
"Unable to load PrismaClient from local generated client or @prisma/client. Run `npm run db:generate` and ensure the generated client exists."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PrismaClientCtor) {
|
||||||
|
throw new Error(
|
||||||
|
"PrismaClient constructor not found in loaded prisma package."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrismaClientType = InstanceType<typeof PrismaClientCtor>;
|
||||||
|
const globalForPrisma = global as unknown as { prisma?: PrismaClientType };
|
||||||
|
|
||||||
|
// --- robust adapter loader & diagnostics ---
|
||||||
|
function tryLoadPgAdapter() {
|
||||||
|
try {
|
||||||
|
// require the package
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||||
|
const mod = require("@prisma/adapter-pg");
|
||||||
|
|
||||||
|
// possible export names we've seen in docs / examples
|
||||||
|
const candidates = [
|
||||||
|
"PrismaPg",
|
||||||
|
"PrismaPgAdapter",
|
||||||
|
"PrismaPgAdapterDefault",
|
||||||
|
"default",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of candidates) {
|
||||||
|
const candidate = (mod &&
|
||||||
|
(mod[name] || (name === "default" && mod.default))) as any;
|
||||||
|
if (typeof candidate === "function") {
|
||||||
|
return { ctor: candidate, usedExport: name, module: mod };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if module itself is a ctor (commonjs default export)
|
||||||
|
if (typeof mod === "function") {
|
||||||
|
return { ctor: mod, usedExport: "moduleAsCtor", module: mod };
|
||||||
|
}
|
||||||
|
|
||||||
|
// no usable export found
|
||||||
|
return { ctor: undefined, usedExport: undefined, module: mod };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPgAdapterInstance(ctor: any) {
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
// different adapter versions accept different option names; attempt common ones
|
||||||
|
const tryOptions = [
|
||||||
|
{ connectionString: dbUrl },
|
||||||
|
{ url: dbUrl },
|
||||||
|
{ connectionString: dbUrl || "" },
|
||||||
|
{ url: dbUrl || "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const opts of tryOptions) {
|
||||||
|
try {
|
||||||
|
const inst = new ctor(opts);
|
||||||
|
return { instance: inst, optsUsed: opts };
|
||||||
|
} catch (err) {
|
||||||
|
// ignore and try next shape
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// final attempt: no args
|
||||||
|
try {
|
||||||
|
return { instance: new ctor(), optsUsed: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { instance: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load adapter only for Postgres projects (your schema shows provider = "postgresql")
|
||||||
|
const adapterLoadResult = tryLoadPgAdapter();
|
||||||
|
|
||||||
|
if (adapterLoadResult.error) {
|
||||||
|
// adapter package couldn't be required at all
|
||||||
|
console.warn(
|
||||||
|
"[prisma-adapter] require('@prisma/adapter-pg') failed:",
|
||||||
|
adapterLoadResult.error.message || adapterLoadResult.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let adapter: any | undefined;
|
||||||
|
|
||||||
|
if (adapterLoadResult.ctor) {
|
||||||
|
const { instance, optsUsed } = createPgAdapterInstance(
|
||||||
|
adapterLoadResult.ctor
|
||||||
|
);
|
||||||
|
adapter = instance;
|
||||||
|
// console.info("[prisma-adapter] Found adapter export:", adapterLoadResult.usedExport, "optsUsed:", optsUsed);
|
||||||
|
} else if (adapterLoadResult.module) {
|
||||||
|
console.warn(
|
||||||
|
"[prisma-adapter] module loaded but no ctor export found. Keys:",
|
||||||
|
Object.keys(adapterLoadResult.module)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If adapter couldn't be constructed, fail loud — constructing PrismaClient without adapter on v7 causes obscure __internal errors.
|
||||||
|
if (!adapter) {
|
||||||
|
const missing = adapterLoadResult.error
|
||||||
|
? "package-not-installed"
|
||||||
|
: "no-export-or-constructor";
|
||||||
|
const msg = [
|
||||||
|
"Prisma adapter for Postgres could not be created.",
|
||||||
|
`reason=${missing}`,
|
||||||
|
"To fix: ensure you have @prisma/adapter-pg installed in the package where this code runs and that its peer 'pg' (node-postgres) is resolvable.",
|
||||||
|
"Examples:",
|
||||||
|
" npm install @prisma/adapter-pg pg",
|
||||||
|
"or in monorepo: npm --workspace packages/db install @prisma/adapter-pg pg",
|
||||||
|
"After installing, run: npm run db:generate and restart dev server.",
|
||||||
|
].join(" ");
|
||||||
|
// throw so we don't instantiate PrismaClient and get the __internal crash
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// instantiate prisma with adapter
|
||||||
export const prisma =
|
export const prisma =
|
||||||
globalForPrisma.prisma || new PrismaClient();
|
globalForPrisma.prisma || new PrismaClientCtor({ adapter });
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { DatabaseBackupUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
import { DatabaseBackupUncheckedCreateInputObjectSchema, BackupDestinationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type DatabaseBackup = z.infer<
|
export type DatabaseBackup = z.infer<
|
||||||
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type BackupDestination = z.infer<
|
||||||
|
typeof BackupDestinationUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./databaseBackup-types";
|
|||||||
export * from "./notifications-types";
|
export * from "./notifications-types";
|
||||||
export * from "./cloudStorage-types";
|
export * from "./cloudStorage-types";
|
||||||
export * from "./payments-reports-types";
|
export * from "./payments-reports-types";
|
||||||
|
export * from "./patientConnection-types";
|
||||||
42
packages/db/types/patientConnection-types.ts
Normal file
42
packages/db/types/patientConnection-types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { CommunicationUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Communication type (Prisma unchecked create input)
|
||||||
|
*/
|
||||||
|
export type Communication = z.infer<
|
||||||
|
typeof CommunicationUncheckedCreateInputObjectSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert Communication
|
||||||
|
* - excludes auto-generated fields
|
||||||
|
*/
|
||||||
|
export const insertCommunicationSchema = (
|
||||||
|
CommunicationUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InsertCommunication = z.infer<
|
||||||
|
typeof insertCommunicationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Communication
|
||||||
|
* - excludes immutable fields
|
||||||
|
* - makes everything optional
|
||||||
|
*/
|
||||||
|
export const updateCommunicationSchema = (
|
||||||
|
CommunicationUncheckedCreateInputObjectSchema as unknown as z.ZodObject<any>
|
||||||
|
)
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
export type UpdateCommunication = z.infer<
|
||||||
|
typeof updateCommunicationSchema
|
||||||
|
>;
|
||||||
@@ -16,5 +16,7 @@ export * from '../shared/schemas/enums/PaymentStatus.schema'
|
|||||||
export * from '../shared/schemas/enums/NotificationTypes.schema'
|
export * from '../shared/schemas/enums/NotificationTypes.schema'
|
||||||
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/NotificationUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/DatabaseBackupUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/BackupDestinationUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFolderUncheckedCreateInput.schema'
|
||||||
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
export * from '../shared/schemas/objects/CloudFileUncheckedCreateInput.schema'
|
||||||
|
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'
|
||||||
Reference in New Issue
Block a user