Merge branch 'develop'
This commit is contained in:
@@ -53,6 +53,7 @@ i.e apps/PatientDataExtractorService/package.json.
|
||||
|
||||
## 📖 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
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ NODE_ENV="development"
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
|
||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||
JWT_SECRET = 'dentalsecret'
|
||||
DB_HOST=localhost
|
||||
DB_USER=postgres
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.17.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import cron from "node-cron";
|
||||
import fs from "fs";
|
||||
import { storage } from "../storage";
|
||||
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
|
||||
* Creates a backup notification if overdue
|
||||
*/
|
||||
export const startBackupCron = () => {
|
||||
cron.schedule("0 9 * * *", async () => {
|
||||
console.log("🔄 Running daily backup check...");
|
||||
cron.schedule("0 2 * * *", async () => {
|
||||
// Every calendar days, at 2 AM
|
||||
// cron.schedule("*/10 * * * * *", async () => { // Every 10 seconds (for Test)
|
||||
|
||||
console.log("🔄 Running backup check...");
|
||||
|
||||
const userBatchSize = 100;
|
||||
let userOffset = 0;
|
||||
@@ -23,7 +28,52 @@ export const startBackupCron = () => {
|
||||
if (user.id == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(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
|
||||
? (Date.now() - new Date(lastBackup.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import app from "./app";
|
||||
import dotenv from "dotenv";
|
||||
import http from "http";
|
||||
import { initSocket } from "./socket";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -11,7 +13,13 @@ const NODE_ENV = (
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
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(
|
||||
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
||||
);
|
||||
|
||||
@@ -126,17 +126,28 @@ router.post(
|
||||
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
|
||||
const allowedKeys = ["INSURANCE_CLAIM", "INSURANCE_CLAIM_PREAUTH"];
|
||||
if (!allowedKeys.includes(groupTitleKey)) {
|
||||
if (!isGroupKey(groupTitleKey)) {
|
||||
return sendError(
|
||||
res,
|
||||
`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
|
||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
||||
parsedPatientId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import fs from "fs";
|
||||
import { prisma } from "@repo/db/client";
|
||||
import { storage } from "../storage";
|
||||
import archiver from "archiver";
|
||||
import { backupDatabaseToPath } from "../services/databaseBackupService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -33,6 +34,8 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const destination = await storage.getActiveBackupDestination(userId);
|
||||
|
||||
// create a unique tmp directory for directory-format dump
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dental_backup_")); // MUST
|
||||
|
||||
@@ -132,12 +135,10 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||
// attempt to respond with error if possible
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({
|
||||
error: "Failed to create archive",
|
||||
details: err.message,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to create archive",
|
||||
details: err.message,
|
||||
});
|
||||
} else {
|
||||
// if streaming already started, destroy the connection
|
||||
res.destroy(err);
|
||||
@@ -187,12 +188,10 @@ router.post("/backup", async (req: Request, res: Response): Promise<any> => {
|
||||
// if headers not sent, send 500; otherwise destroy
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({
|
||||
error: "Failed to finalize archive",
|
||||
details: String(err),
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to finalize archive",
|
||||
details: String(err),
|
||||
});
|
||||
} else {
|
||||
res.destroy(err);
|
||||
}
|
||||
@@ -222,9 +221,9 @@ router.get("/status", async (req: Request, res: Response): Promise<any> => {
|
||||
return res.status(401).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const size = await prisma.$queryRawUnsafe<{ size: string }[]>(
|
||||
"SELECT pg_size_pretty(pg_database_size(current_database())) as size"
|
||||
);
|
||||
const size = await prisma.$queryRaw<{ size: string }[]>`
|
||||
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||
`;
|
||||
|
||||
const patientsCount = await storage.getTotalPatientCount();
|
||||
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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import patientDataExtractionRoutes from "./patientDataExtraction";
|
||||
import insuranceCredsRoutes from "./insuranceCreds";
|
||||
import documentsRoutes from "./documents";
|
||||
import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -27,6 +28,7 @@ router.use("/claims", claimsRoutes);
|
||||
router.use("/insuranceCreds", insuranceCredsRoutes);
|
||||
router.use("/documents", documentsRoutes);
|
||||
router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
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");
|
||||
|
||||
// 1) Check existence
|
||||
const existing = await storage.getInsuranceCredential(userId);
|
||||
const existing = await storage.getInsuranceCredential(id);
|
||||
if (!existing)
|
||||
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";
|
||||
|
||||
export interface IStorage {
|
||||
@@ -7,6 +7,33 @@ export interface IStorage {
|
||||
getLastBackup(userId: number): Promise<DatabaseBackup | null>;
|
||||
getBackups(userId: number, limit?: number): Promise<DatabaseBackup[]>;
|
||||
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 = {
|
||||
@@ -36,4 +63,51 @@ export const databaseBackupStorage: IStorage = {
|
||||
const result = await db.databaseBackup.deleteMany({ where: { userId } });
|
||||
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-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.15.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -12,6 +12,7 @@ import Dashboard from "./pages/dashboard";
|
||||
import LoadingScreen from "./components/ui/LoadingScreen";
|
||||
|
||||
const AuthPage = lazy(() => import("./pages/auth-page"));
|
||||
const PatientConnectionPage = lazy(() => import("./pages/patient-connection-page"));
|
||||
const AppointmentsPage = lazy(() => import("./pages/appointments-page"));
|
||||
const PatientsPage = lazy(() => import("./pages/patients-page"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings-page"));
|
||||
@@ -34,6 +35,7 @@ function Router() {
|
||||
<ProtectedRoute path="/" component={() => <Redirect to="/insurance-status" />} />
|
||||
|
||||
<ProtectedRoute path="/dashboard" component={() => <Dashboard />} />
|
||||
<ProtectedRoute path="/patient-connection" component={() => <PatientConnectionPage />} />
|
||||
<ProtectedRoute
|
||||
path="/appointments"
|
||||
component={() => <AppointmentsPage />}
|
||||
|
||||
@@ -964,6 +964,9 @@ export function ClaimForm({
|
||||
"childRecallDirect4BW",
|
||||
"childRecallDirect2PA2BW",
|
||||
"childRecallDirect2PA4BW",
|
||||
"childRecallDirect3PA2BW",
|
||||
"childRecallDirect3PA",
|
||||
"childRecallDirect4PA",
|
||||
"childRecallDirectPANO",
|
||||
].map((comboId) => {
|
||||
const b = PROCEDURE_COMBOS[comboId];
|
||||
@@ -979,6 +982,9 @@ export function ClaimForm({
|
||||
childRecallDirect4BW: "Direct 4BW",
|
||||
childRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
childRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
childRecallDirect3PA2BW: "Direct 3PA 2BW",
|
||||
childRecallDirect3PA: "Direct 3PA",
|
||||
childRecallDirect4PA: "Direct 4PA",
|
||||
childRecallDirectPANO: "Direct Pano",
|
||||
};
|
||||
return (
|
||||
@@ -1015,6 +1021,7 @@ export function ClaimForm({
|
||||
"adultRecallDirect4BW",
|
||||
"adultRecallDirect2PA2BW",
|
||||
"adultRecallDirect2PA4BW",
|
||||
"adultRecallDirect4PA",
|
||||
"adultRecallDirectPano",
|
||||
].map((comboId) => {
|
||||
const b = PROCEDURE_COMBOS[comboId];
|
||||
@@ -1030,6 +1037,7 @@ export function ClaimForm({
|
||||
adultRecallDirect4BW: "Direct 4BW",
|
||||
adultRecallDirect2PA2BW: "Direct 2PA 2BW",
|
||||
adultRecallDirect2PA4BW: "Direct 2PA 4BW",
|
||||
adultRecallDirect4PA: "Direct 4PA",
|
||||
adultRecallDirectPano: "Direct Pano",
|
||||
};
|
||||
return (
|
||||
@@ -1053,6 +1061,45 @@ export function ClaimForm({
|
||||
})}
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
FileText,
|
||||
Cloud,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
@@ -27,6 +28,11 @@ export function Sidebar() {
|
||||
path: "/dashboard",
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Patient Connection",
|
||||
path: "/patient-connection",
|
||||
icon: <Phone className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "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
|
||||
let message = `${res.status}: ${res.statusText}`;
|
||||
|
||||
try {
|
||||
const errorBody = await res.json();
|
||||
if (errorBody?.message) {
|
||||
const errorBody = await res.clone().json();
|
||||
|
||||
if (errorBody?.error) {
|
||||
message = errorBody.error;
|
||||
} else if (errorBody?.message) {
|
||||
message = errorBody.message;
|
||||
} else if (errorBody?.detail) {
|
||||
message = errorBody.detail;
|
||||
}
|
||||
} 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);
|
||||
|
||||
@@ -914,6 +914,14 @@ export default function AppointmentsPage() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { formatDateToHumanReadable } from "@/utils/dateUtils";
|
||||
import { BackupDestinationManager } from "@/components/database-management/backup-destination-manager";
|
||||
|
||||
export default function DatabaseManagementPage() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -205,6 +206,9 @@ export default function DatabaseManagementPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Externa Drive automatic backup manager */}
|
||||
<BackupDestinationManager />
|
||||
|
||||
{/* Database Status Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DateInput } from "@/components/ui/dateInput";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
|
||||
import { useLocation } from "wouter";
|
||||
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -43,7 +44,7 @@ export default function InsuranceStatusPage() {
|
||||
const [dateOfBirth, setDateOfBirth] = useState<Date | null>(null);
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const isFormIncomplete = !memberId || !dateOfBirth || !firstName || !lastName;
|
||||
const isFormIncomplete = !memberId || !dateOfBirth;
|
||||
const [isCheckingEligibilityStatus, setIsCheckingEligibilityStatus] =
|
||||
useState(false);
|
||||
const [isCheckingClaimStatus, setIsCheckingClaimStatus] = useState(false);
|
||||
@@ -574,19 +575,25 @@ export default function InsuranceStatusPage() {
|
||||
{/* TEMP PROVIDER BUTTONS */}
|
||||
<div className="space-y-4 mt-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Other provider checks (not working)
|
||||
Other provider checks
|
||||
</h3>
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta MA
|
||||
</Button>
|
||||
<DdmaEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
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"],
|
||||
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: {
|
||||
id: "childRecallDirectPANO",
|
||||
label: "Child Recall Direct PANO",
|
||||
@@ -77,6 +101,11 @@ export const PROCEDURE_COMBOS: Record<
|
||||
codes: ["D0120", "D0220", "D0230", "D0274", "D1110"],
|
||||
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: {
|
||||
id: "adultRecallDirectPano",
|
||||
label: "Adult Recall Direct - PANO",
|
||||
@@ -220,6 +249,33 @@ export const PROCEDURE_COMBOS: Record<
|
||||
label: "Baby Teeth EXT",
|
||||
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…
|
||||
};
|
||||
|
||||
@@ -263,4 +319,5 @@ export const COMBO_CATEGORIES: Record<
|
||||
"surgicalExtraction",
|
||||
"babyTeethExtraction",
|
||||
],
|
||||
Orthodontics: ["orthPA"],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
import asyncio
|
||||
@@ -7,6 +7,8 @@ from selenium_eligibilityCheckWorker import AutomationMassHealthEligibilityCheck
|
||||
from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck
|
||||
from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||
import os
|
||||
import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
@@ -137,6 +139,79 @@ async def start_workflow(request: Request):
|
||||
async with lock:
|
||||
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
|
||||
@app.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",
|
||||
"check-types": "turbo run check-types",
|
||||
"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:migrate": "dotenv -e packages/db/.env -- prisma migrate dev --schema=packages/db/prisma/schema.prisma",
|
||||
"db:seed": "prisma db seed --schema=packages/db/prisma/schema.prisma",
|
||||
"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": "npx prisma migrate dev --config=packages/db/prisma/prisma.config.ts --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",
|
||||
"postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall"
|
||||
},
|
||||
@@ -21,7 +21,6 @@
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prisma": "^6.16.2",
|
||||
"turbo": "^2.5.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -33,11 +32,12 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@prisma/client": "^7.0.1",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"decimal.js": "^10.6.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"prisma": "^7.0.1",
|
||||
"react-redux": "^9.2.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
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
# 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",
|
||||
"exports": {
|
||||
"./client": "./src/index.ts",
|
||||
"./shared/schemas" : "./shared/schemas/index.ts",
|
||||
"./usedSchemas" : "./usedSchemas/index.ts",
|
||||
"./generated/prisma" : "./generated/prisma/index.d.ts",
|
||||
"./types" : "./types/index.ts"
|
||||
"./shared/schemas": "./shared/schemas/index.ts",
|
||||
"./usedSchemas": "./usedSchemas/index.ts",
|
||||
"./types": "./types/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.10.0",
|
||||
"prisma-zod-generator": "^0.8.13"
|
||||
"@prisma/adapter-pg": "^7.0.1",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"prisma-zod-generator": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
"prisma": "^6.7.0",
|
||||
"prisma": "^7.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"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 {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -30,16 +29,18 @@ model User {
|
||||
insuranceCredentials InsuranceCredential[]
|
||||
updatedPayments Payment[] @relation("PaymentUpdatedBy")
|
||||
backups DatabaseBackup[]
|
||||
backupDestinations BackupDestination[]
|
||||
notifications Notification[]
|
||||
cloudFolders CloudFolder[]
|
||||
cloudFiles CloudFile[]
|
||||
communications Communication[]
|
||||
}
|
||||
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
firstName String
|
||||
lastName String
|
||||
dateOfBirth DateTime @db.Date
|
||||
dateOfBirth DateTime @db.Date
|
||||
gender String
|
||||
phone String
|
||||
email String?
|
||||
@@ -52,14 +53,15 @@ model Patient {
|
||||
policyHolder String?
|
||||
allergies String?
|
||||
medicalConditions String?
|
||||
status PatientStatus @default(UNKNOWN)
|
||||
status PatientStatus @default(UNKNOWN)
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
appointments Appointment[]
|
||||
claims Claim[]
|
||||
groups PdfGroup[]
|
||||
payment Payment[]
|
||||
communications Communication[]
|
||||
|
||||
@@index([insuranceId])
|
||||
@@index([createdAt])
|
||||
@@ -110,22 +112,22 @@ model Staff {
|
||||
}
|
||||
|
||||
model Claim {
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
appointmentId Int
|
||||
userId Int
|
||||
staffId Int
|
||||
patientName String
|
||||
memberId String
|
||||
dateOfBirth DateTime @db.Date
|
||||
remarks String
|
||||
id Int @id @default(autoincrement())
|
||||
patientId Int
|
||||
appointmentId Int
|
||||
userId Int
|
||||
staffId Int
|
||||
patientName String
|
||||
memberId String
|
||||
dateOfBirth DateTime @db.Date
|
||||
remarks String
|
||||
missingTeethStatus MissingTeethStatus @default(No_missing)
|
||||
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
||||
serviceDate DateTime
|
||||
insuranceProvider String // e.g., "Delta MA"
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status ClaimStatus @default(PENDING)
|
||||
missingTeeth Json? // { "T_14": "X", "T_G": "O", ... }
|
||||
serviceDate DateTime
|
||||
insuranceProvider String // e.g., "Delta MA"
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
status ClaimStatus @default(PENDING)
|
||||
|
||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
||||
appointment Appointment @relation(fields: [appointmentId], references: [id], onDelete: Cascade)
|
||||
@@ -300,6 +302,7 @@ enum PaymentMethod {
|
||||
OTHER
|
||||
}
|
||||
|
||||
// Database management page
|
||||
model DatabaseBackup {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
@@ -311,6 +314,16 @@ model DatabaseBackup {
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
@@ -379,3 +392,46 @@ model CloudFileChunk {
|
||||
@@unique([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 =
|
||||
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";
|
||||
|
||||
export type DatabaseBackup = z.infer<
|
||||
typeof DatabaseBackupUncheckedCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
export type BackupDestination = z.infer<
|
||||
typeof BackupDestinationUncheckedCreateInputObjectSchema
|
||||
>;
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./databaseBackup-types";
|
||||
export * from "./notifications-types";
|
||||
export * from "./cloudStorage-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/objects/NotificationUncheckedCreateInput.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/CloudFileUncheckedCreateInput.schema'
|
||||
export * from '../shared/schemas/objects/CommunicationUncheckedCreateInput.schema'
|
||||
Reference in New Issue
Block a user