feat(ddma-eligibility) - v1

This commit is contained in:
2025-11-25 19:23:24 +05:30
parent 394dbc359b
commit 4ceffdd073
14 changed files with 1881 additions and 14 deletions

View File

@@ -2,6 +2,7 @@ NODE_ENV="development"
HOST=0.0.0.0 HOST=0.0.0.0
PORT=5000 PORT=5000
FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000 FRONTEND_URLS=http://localhost:3000,http://192.168.1.8:3000
SELENIUM_AGENT_BASE_URL=http://localhost:5002
JWT_SECRET = 'dentalsecret' JWT_SECRET = 'dentalsecret'
DB_HOST=localhost DB_HOST=localhost
DB_USER=postgres DB_USER=postgres

View File

@@ -27,6 +27,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"socket.io": "^4.8.1",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.24.2", "zod": "^3.24.2",
"zod-validation-error": "^3.4.0" "zod-validation-error": "^3.4.0"

View File

@@ -1,5 +1,7 @@
import app from "./app"; import app from "./app";
import dotenv from "dotenv"; import dotenv from "dotenv";
import http from "http";
import { initSocket } from "./socket";
dotenv.config(); dotenv.config();
@@ -11,7 +13,13 @@ const NODE_ENV = (
const HOST = process.env.HOST || "0.0.0.0"; const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT) || 5000; const PORT = Number(process.env.PORT) || 5000;
const server = app.listen(PORT, HOST, () => { // HTTP server from express app
const server = http.createServer(app);
// Initialize socket.io on this server
initSocket(server);
server.listen(PORT, HOST, () => {
console.log( console.log(
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}` `✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
); );

View File

@@ -8,6 +8,7 @@ import patientDataExtractionRoutes from "./patientDataExtraction";
import insuranceCredsRoutes from "./insuranceCreds"; import insuranceCredsRoutes from "./insuranceCreds";
import documentsRoutes from "./documents"; import documentsRoutes from "./documents";
import insuranceStatusRoutes from "./insuranceStatus"; import insuranceStatusRoutes from "./insuranceStatus";
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
import paymentsRoutes from "./payments"; import paymentsRoutes from "./payments";
import databaseManagementRoutes from "./database-management"; import databaseManagementRoutes from "./database-management";
import notificationsRoutes from "./notifications"; import notificationsRoutes from "./notifications";
@@ -27,6 +28,7 @@ router.use("/claims", claimsRoutes);
router.use("/insuranceCreds", insuranceCredsRoutes); router.use("/insuranceCreds", insuranceCredsRoutes);
router.use("/documents", documentsRoutes); router.use("/documents", documentsRoutes);
router.use("/insurance-status", insuranceStatusRoutes); router.use("/insurance-status", insuranceStatusRoutes);
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
router.use("/payments", paymentsRoutes); router.use("/payments", paymentsRoutes);
router.use("/database-management", databaseManagementRoutes); router.use("/database-management", databaseManagementRoutes);
router.use("/notifications", notificationsRoutes); router.use("/notifications", notificationsRoutes);

View File

@@ -0,0 +1,443 @@
import { Router, Request, Response } from "express";
import { storage } from "../storage";
import {
forwardToSeleniumDdmaEligibilityAgent,
forwardOtpToSeleniumDdmaAgent,
getSeleniumDdmaSessionStatus,
} from "../services/seleniumDdmaInsuranceEligibilityClient";
import fs from "fs/promises";
import path from "path";
import { emptyFolderContainingFile } from "../utils/emptyTempFolder";
import forwardToPatientDataExtractorService from "../services/patientDataExtractorService";
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)
}
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 };
}
/**
* 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) {
console.warn(
"Failed to validate patient payload in ddma insurance flow:",
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 = {};
const extracted: any = {};
const insuranceEligibilityData = job.insuranceEligibilityData;
// 1) Extract name from PDF if available
if (
seleniumResult?.pdf_path &&
typeof seleniumResult.pdf_path === "string" &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
try {
const pdfPath = seleniumResult.pdf_path;
const pdfBuffer = await fs.readFile(pdfPath);
const extraction = await forwardToPatientDataExtractorService({
buffer: pdfBuffer,
originalname: path.basename(pdfPath),
mimetype: "application/pdf",
} as any);
if (extraction.name) {
const parts = splitName(extraction.name);
extracted.firstName = parts.firstName;
extracted.lastName = parts.lastName;
}
} catch (err: any) {
outputResult.extractionError =
err?.message ?? "Patient data extraction failed";
}
}
// 2) Create or update patient
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
if (!insuranceId) {
throw new Error("Missing memberId for ddma job");
}
const preferFirst = extracted.firstName;
const preferLast = extracted.lastName;
await createOrUpdatePatientByInsuranceId({
insuranceId,
firstName: preferFirst,
lastName: preferLast,
dob: insuranceEligibilityData.dateOfBirth,
userId: job.userId,
});
// 3) Update patient status + PDF upload
const patient = await storage.getPatientByInsuranceId(
insuranceEligibilityData.memberId
);
if (patient && patient.id !== undefined) {
const newStatus =
seleniumResult.eligibility === "Y" ? "ACTIVE" : "INACTIVE";
await storage.updatePatient(patient.id, { status: newStatus });
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}`;
if (
seleniumResult.pdf_path &&
typeof seleniumResult.pdf_path === "string" &&
seleniumResult.pdf_path.endsWith(".pdf")
) {
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
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(seleniumResult.pdf_path),
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.";
}
} else {
outputResult.patientUpdateStatus =
"Patient not found or missing ID; no update performed";
}
// 4) Cleanup PDF temp folder
try {
if (seleniumResult && seleniumResult.pdf_path) {
await emptyFolderContainingFile(seleniumResult.pdf_path);
}
} catch (cleanupErr) {
console.error(
`[ddma-eligibility cleanup failed for ${seleniumResult?.pdf_path}]`,
cleanupErr
);
}
return {
patientUpdateStatus: outputResult.patientUpdateStatus,
pdfUploadStatus: outputResult.pdfUploadStatus,
pdfFileId: createdPdfFileId,
};
}
/**
* Polls Python agent for session status and emits socket events:
* - 'selenium:otp_required' when waiting_for_otp
* - 'selenium:session_update' when completed/error
*/
async function pollAgentSessionAndProcess(
sessionId: string,
socketId?: string
) {
const maxAttempts = 300; // ~5 minutes @ 1s
const delayMs = 1000;
const job = ddmaJobs[sessionId];
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const st = await getSeleniumDdmaSessionStatus(sessionId);
const status = st?.status;
if (status === "waiting_for_otp") {
if (socketId && io && io.sockets.sockets.get(socketId)) {
io.to(socketId).emit("selenium:otp_required", {
session_id: sessionId,
message: "OTP required. Please enter the OTP.",
});
}
// once waiting_for_otp, we stop polling here; OTP flow continues separately
return;
}
if (status === "completed") {
// run DB + PDF pipeline
let finalResult: any = null;
if (job && st.result) {
try {
finalResult = await handleDdmaCompletedJob(
sessionId,
job,
st.result
);
} catch (err: any) {
finalResult = {
error: "Failed to process ddma completed job",
detail: err?.message ?? String(err),
};
}
}
if (socketId && io && io.sockets.sockets.get(socketId)) {
io.to(socketId).emit("selenium:session_update", {
session_id: sessionId,
status: "completed",
rawSelenium: st.result,
final: finalResult,
});
}
delete ddmaJobs[sessionId];
return;
}
if (status === "error" || status === "not_found") {
if (socketId && io && io.sockets.sockets.get(socketId)) {
io.to(socketId).emit("selenium:session_update", {
session_id: sessionId,
status,
message: st?.message || "Selenium session error",
});
}
delete ddmaJobs[sessionId];
return;
}
} catch (err) {
// swallow transient errors and keep polling
console.warn("pollAgentSessionAndProcess error", err);
}
await new Promise((r) => setTimeout(r, delayMs));
}
// fallback: timeout
if (socketId && io && io.sockets.sockets.get(socketId)) {
io.to(socketId).emit("selenium:session_update", {
session_id: sessionId,
status: "error",
message: "Polling timeout while waiting for selenium session",
});
}
}
/**
* 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,
};
// 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);
// notify socket that OTP was accepted (if socketId present)
try {
const { io } = require("../socket");
if (socketId && io && io.sockets.sockets.get(socketId)) {
io.to(socketId).emit("selenium:otp_submitted", {
session_id: sessionId,
result: r,
});
}
} catch (emitErr) {
console.warn("Failed to emit selenium:otp_submitted", emitErr);
}
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,
});
}
}
);
export default router;

View File

@@ -0,0 +1,72 @@
import axios from "axios";
import dotenv from "dotenv";
dotenv.config();
export interface SeleniumPayload {
data: any;
url?: string;
}
const SELENIUM_AGENT_BASE =
process.env.SELENIUM_AGENT_BASE_URL;
export async function forwardToSeleniumDdmaEligibilityAgent(
insuranceEligibilityData: any,
): Promise<any> {
const payload: SeleniumPayload = {
data: insuranceEligibilityData,
};
const url = `${SELENIUM_AGENT_BASE}/ddma-eligibility`;
console.log(url)
const result = await axios.post(
`${SELENIUM_AGENT_BASE}/ddma-eligibility`,
payload,
{ timeout: 5 * 60 * 1000 }
);
if (!result || !result.data) {
throw new Error("Empty response from selenium agent");
}
if (result.data.status === "error") {
const errorMsg =
typeof result.data.message === "string"
? result.data.message
: result.data.message?.msg || "Selenium agent error";
throw new Error(errorMsg);
}
return result.data; // { status: "started", session_id }
}
export async function forwardOtpToSeleniumDdmaAgent(
sessionId: string,
otp: string
): Promise<any> {
const result = await axios.post(`${SELENIUM_AGENT_BASE}/submit-otp`, {
session_id: sessionId,
otp,
});
if (!result || !result.data) throw new Error("Empty OTP response");
if (result.data.status === "error") {
const message =
typeof result.data.message === "string"
? result.data.message
: JSON.stringify(result.data);
throw new Error(message);
}
return result.data;
}
export async function getSeleniumDdmaSessionStatus(
sessionId: string
): Promise<any> {
const result = await axios.get(
`${SELENIUM_AGENT_BASE}/session/${sessionId}/status`
);
if (!result || !result.data) throw new Error("Empty session status");
return result.data;
}

View 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 };

View File

@@ -76,6 +76,7 @@
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View File

@@ -0,0 +1,426 @@
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";
// Use Vite env (set VITE_BACKEND_URL in your frontend .env)
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;
};
}, []);
// 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();
});
socket.on("connect_error", (err) => {
console.error("DDMA socket connect_error:", err);
reject(err);
});
socket.on("disconnect", () => {
console.log("DDMA socket disconnected");
});
// 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 || payload.session_id !== sessionId) 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 || session_id !== sessionId) 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",
});
setSessionId(null);
setOtpModalOpen(false);
}
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
});
});
connectingRef.current = promise;
try {
await promise;
} finally {
// Once resolved or rejected, allow future attempts if needed
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,
}
);
const result = await response.json();
if (!response.ok || result.error) {
throw new Error(result.error || "DDMA selenium start failed");
}
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}
/>
</>
);
}

View File

@@ -27,6 +27,7 @@ import { DateInput } from "@/components/ui/dateInput";
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table"; import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal"; import { PdfPreviewModal } from "@/components/insurance-status/pdf-preview-modal";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DdmaEligibilityButton } from "@/components/insurance-status/ddma-buton-modal";
export default function InsuranceStatusPage() { export default function InsuranceStatusPage() {
const { user } = useAuth(); const { user } = useAuth();
@@ -574,19 +575,25 @@ export default function InsuranceStatusPage() {
{/* TEMP PROVIDER BUTTONS */} {/* TEMP PROVIDER BUTTONS */}
<div className="space-y-4 mt-6"> <div className="space-y-4 mt-6">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-sm font-medium text-muted-foreground">
Other provider checks (not working) Other provider checks
</h3> </h3>
{/* Row 1 */} {/* Row 1 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button <DdmaEligibilityButton
className="w-full" memberId={memberId}
variant="outline" dateOfBirth={dateOfBirth}
disabled={isFormIncomplete} firstName={firstName}
> lastName={lastName}
<CheckCircle className="h-4 w-4 mr-2" /> isFormIncomplete={isFormIncomplete}
Delta MA onPdfReady={(pdfId, fallbackFilename) => {
</Button> setPreviewPdfId(pdfId);
setPreviewFallbackFilename(
fallbackFilename ?? `eligibility_ddma_${memberId}.pdf`
);
setPreviewOpen(true);
}}
/>
<Button <Button
className="w-full" className="w-full"

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
import asyncio import asyncio
@@ -7,6 +7,8 @@ from selenium_eligibilityCheckWorker import AutomationMassHealthEligibilityCheck
from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck from selenium_claimStatusCheckWorker import AutomationMassHealthClaimStatusCheck
from selenium_preAuthWorker import AutomationMassHealthPreAuth from selenium_preAuthWorker import AutomationMassHealthPreAuth
import os import os
import time
import helpers_ddma_eligibility as hddma
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -137,6 +139,79 @@ async def start_workflow(request: Request):
async with lock: async with lock:
active_jobs -= 1 active_jobs -= 1
# Endpoint:5 - DDMA eligibility (background, OTP)
async def _ddma_worker_wrapper(sid: str, data: dict, url: str):
"""
Background worker that:
- acquires semaphore (to keep 1 selenium at a time),
- updates active/queued counters,
- runs the DDMA flow via helpers.start_ddma_run.
"""
global active_jobs, waiting_jobs
async with semaphore:
async with lock:
waiting_jobs -= 1
active_jobs += 1
try:
await hddma.start_ddma_run(sid, data, url)
finally:
async with lock:
active_jobs -= 1
@app.post("/ddma-eligibility")
async def ddma_eligibility(request: Request):
"""
Starts a DDMA eligibility session in the background.
Body: { "data": { ... }, "url"?: string }
Returns: { status: "started", session_id: "<uuid>" }
"""
global waiting_jobs
body = await request.json()
data = body.get("data", {})
# create session
sid = hddma.make_session_entry()
hddma.sessions[sid]["type"] = "ddma_eligibility"
hddma.sessions[sid]["last_activity"] = time.time()
async with lock:
waiting_jobs += 1
# run in background (queued under semaphore)
asyncio.create_task(_ddma_worker_wrapper(sid, data, url="https://providers.deltadentalma.com/onboarding/start/"))
return {"status": "started", "session_id": sid}
@app.post("/submit-otp")
async def submit_otp(request: Request):
"""
Body: { "session_id": "<sid>", "otp": "123456" }
Node / frontend call this when user provides OTP.
"""
body = await request.json()
sid = body.get("session_id")
otp = body.get("otp")
if not sid or not otp:
raise HTTPException(status_code=400, detail="session_id and otp required")
res = hddma.submit_otp(sid, otp)
if res.get("status") == "error":
raise HTTPException(status_code=400, detail=res.get("message"))
return res
@app.get("/session/{sid}/status")
async def session_status(sid: str):
s = hddma.get_session_status(sid)
if s.get("status") == "not_found":
raise HTTPException(status_code=404, detail="session not found")
return s
# ✅ Status Endpoint # ✅ Status Endpoint
@app.get("/status") @app.get("/status")
async def get_status(): async def get_status():

View File

@@ -0,0 +1,212 @@
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_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):
"""Close driver (if any) and remove session entry."""
s = sessions.get(sid)
if not s:
return
try:
driver = s.get("driver")
if driver:
try:
driver.quit()
except Exception:
pass
finally:
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
login_result = bot.login()
# 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,
}

View File

@@ -0,0 +1,272 @@
from selenium import webdriver
from selenium.common import TimeoutException
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
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 shutil
import stat
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):
"""
Attempts login and detects OTP.
Returns:
- "Success" -> logged in
- "OTP_REQUIRED" -> page requires OTP (we do NOT block here)
- "ERROR:..." -> error occurred
"""
wait = WebDriverWait(self.driver, 30)
try:
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)
# Click Remember me checkbox
remember_me_checkbox = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//label[.//span[contains(text(),'Remember me')]]")
))
remember_me_checkbox.click()
login_button = wait.until(EC.element_to_be_clickable((By.XPATH, "//button[@type='button' and @aria-label='Sign in']")))
login_button.click()
# 1) Detect OTP presence (adjust the XPath/attributes to the actual site)
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("[DDMA worker] OTP input detected -> OTP_REQUIRED")
return "OTP_REQUIRED"
except TimeoutException:
pass
# 2) Detect successful login by presence of a known post-login element
try:
logged_in_el = WebDriverWait(self.driver, 5).until(
EC.presence_of_element_located((By.XPATH, "//a[text()='Member Eligibility' or contains(., 'Member Eligibility')]"))
)
if logged_in_el:
return "Success"
except TimeoutException:
# last chance: see if URL changed
if "dashboard" in self.driver.current_url or "providers" in self.driver.current_url:
return "Success"
return "ERROR:LOGIN FAILED - unable to detect success or OTP"
except Exception as e:
print(f"[DDMA worker] login exception: {e}")
return f"ERROR:LOGIN FAILED: {e}"
def step1(self):
wait = WebDriverWait(self.driver, 30)
try:
eligibility_link = wait.until(
EC.element_to_be_clickable((By.XPATH, "//a[text()='Member Eligibility']"))
)
eligibility_link.click()
time.sleep(3)
# Fill Member ID
member_id_input = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Text1"]')))
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"
wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Text2"]'))).send_keys(month)
wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Text3"]'))).send_keys(day)
wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="Text4"]'))).send_keys(year)
# Click Continue button
continue_btn = wait.until(EC.element_to_be_clickable((By.XPATH, '//input[@type="submit" and @value="Add Member"]')))
continue_btn.click()
# Check for error message
try:
error_msg = WebDriverWait(self.driver, 5).until(EC.presence_of_element_located(
(By.XPATH, "//td[@class='text_err_msg' and contains(text(), 'Invalid Medicaid ID or Date of Birth')]")
))
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):
def wait_for_pdf_download(timeout=60):
for _ in range(timeout):
files = [f for f in os.listdir(self.download_dir) if f.endswith(".pdf")]
if files:
return os.path.join(self.download_dir, files[0])
time.sleep(1)
raise TimeoutError("PDF did not download in time")
def _unique_target_path():
"""
Create a unique filename using memberId.
"""
safe_member = "".join(c for c in str(self.memberId) if c.isalnum() or c in "-_.")
filename = f"eligibility_{safe_member}.pdf"
return os.path.join(self.download_dir, filename)
wait = WebDriverWait(self.driver, 90)
tmp_created_path = None
try:
eligibilityElement = wait.until(EC.presence_of_element_located((By.XPATH,
f"//table[@id='Table3']//tr[td[contains(text(), '{self.memberId}')]]/td[3]")))
eligibilityText = eligibilityElement.text
txReportElement = wait.until(EC.element_to_be_clickable((By.XPATH,
f"//table[@id='Table3']//tr[td[contains(text(), '{self.memberId}')]]//input[@value='Tx Report']"
)))
txReportElement.click()
# wait for the PDF to fully appear
downloaded_path = wait_for_pdf_download()
# generate unique target path (include memberId)
target_path = _unique_target_path()
# It's possible Chrome writes a file with a fixed name: copy/rename it to our target name.
shutil.copyfile(downloaded_path, target_path)
# ensure the copied file is writable / stable
os.chmod(target_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
print("PDF downloaded at:", target_path)
return {
"status": "success",
"eligibility": eligibilityText,
"pdf_path": target_path
}
except Exception as e:
print(f"ERROR: {str(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:
if self.driver:
self.driver.quit()
def main_workflow(self, url):
try:
self.config_driver()
self.driver.maximize_window()
self.driver.get(url)
time.sleep(3)
login_result = self.login()
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

300
package-lock.json generated
View File

@@ -51,6 +51,7 @@
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"socket.io": "^4.8.1",
"ws": "^8.18.0", "ws": "^8.18.0",
"zod": "^3.24.2", "zod": "^3.24.2",
"zod-validation-error": "^3.4.0" "zod-validation-error": "^3.4.0"
@@ -142,6 +143,7 @@
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -3773,6 +3775,12 @@
"integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==", "integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@@ -4252,7 +4260,6 @@
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -4438,7 +4445,6 @@
"version": "20.16.11", "version": "20.16.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz",
"integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
@@ -5295,6 +5301,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.25", "version": "2.8.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
@@ -6492,6 +6507,133 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -11005,6 +11147,151 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -12033,7 +12320,6 @@
"version": "6.19.8", "version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": { "node_modules/unicode-properties": {
@@ -12567,6 +12853,14 @@
} }
} }
}, },
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",